import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { MessageCircle, Trash2 } from "lucide-react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { PullRequestMarkdownTextarea, type PullRequestCompletionScope, } from "~/components/pullRequest/PullRequestMarkdownTextarea"; import { SpeechTextField, TextFieldSpeechControls, type TextFieldSpeech, } from "~/speech/useTextFieldSpeech"; import { textDirection } from "~/textDirection"; import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; interface DiffCommentSecondaryAction { readonly label: string; readonly icon?: ReactNode; readonly allowEmpty?: boolean; readonly onAction: (text: string) => void; } interface DiffCommentAnnotationProps { /** With an environment, the draft can be dictated; its project supplies vocabulary. */ environmentId?: EnvironmentId | undefined; projectId?: ProjectId | null | undefined; ownerKey?: string; kind: "draft" | "comment"; rangeLabel: string; text: string; onTextChange?: (text: string) => void; onCancel: () => void; onComment: (text: string) => void; onDelete?: () => void; placeholder?: string; submitLabel?: string; pending?: boolean; secondaryAction?: DiffCommentSecondaryAction; focusOnMount?: boolean; /** Set only for pull request comments, where `@` completes the repository's users. */ completionScope?: PullRequestCompletionScope | null; } export function DiffCommentAnnotation({ environmentId, projectId, ownerKey, kind, rangeLabel, text, onTextChange, onCancel, onComment, onDelete, placeholder = "Add a comment…", submitLabel = "Comment", pending = false, secondaryAction, focusOnMount = true, completionScope = null, }: DiffCommentAnnotationProps) { const [localDraftText, setLocalDraftText] = useState(""); const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; const trimmedText = displayedText.trim(); const textareaRef = useRef(null); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the draft textarea on the next animation frame after mount useLayoutEffect(() => { if (kind !== "draft" || !focusOnMount) return; const frame = window.requestAnimationFrame(() => { textareaRef.current?.focus({ preventScroll: true }); }); return () => window.cancelAnimationFrame(frame); }, [focusOnMount, kind]); if (kind === "comment") { return (
event.stopPropagation()} >
); } const submit = ( speech: TextFieldSpeech | undefined, action: (text: string) => void, allowEmpty = false, ) => { const finish = (value: string) => { if (!pending && (allowEmpty || value.trim())) action(value.trim()); }; if (speech) speech.submitAfterDictation(finish); else finish(displayedText); }; // Pierre hides native carets and selection; textarea comments need their own visible feedback. const renderDraft = (speech?: TextFieldSpeech) => (
event.stopPropagation()} > { const end = event.currentTarget.value.length; event.currentTarget.setSelectionRange(end, end); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); if (speech && speech.state.phase !== "idle") speech.cancel(); else onCancel(); } if (isCommentSubmitShortcut(event, trimmedText, pending, speech?.blocksSubmission)) { event.preventDefault(); submit(speech, onComment); } }} />
⌘/Ctrl Enter to send {speech ? : null} {secondaryAction ? ( ) : null}
); return environmentId ? ( {renderDraft} ) : ( renderDraft() ); }