import React, { useRef, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { Icon } from '@/components/icon/Icon'; import { useDeviceInfo } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; import { formatShortcutForDisplay } from '@/lib/shortcuts'; export interface InlineCommentInputProps { initialText?: string; onTextChange?: (text: string) => void; onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void; onCancel: () => void; fileLabel?: string; lineRange?: { start: number; end: number; side?: 'additions' | 'deletions' }; isEditing?: boolean; className?: string; maxWidth?: number; } /** * The comment editor shown under selected diff/editor lines. Styled as the * same pill used by chat quote comments and browser annotations: a rounded * auto-growing textarea with a round attach button, and a muted context line * above naming the file and range. */ export function InlineCommentInput({ initialText = '', onTextChange, onSave, onCancel, fileLabel, lineRange, isEditing = false, className, maxWidth, }: InlineCommentInputProps) { const { t } = useI18n(); const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); const saveShortcut = formatShortcutForDisplay('mod+enter'); void isEditing; const handleTextChange = (value: string) => { setText(value); onTextChange?.(value); resizeTextarea(); }; const resizeTextarea = () => { const element = textareaRef.current; if (!element) return; element.style.height = 'auto'; element.style.height = `${Math.min(element.scrollHeight, 120)}px`; }; // Stable range snapshot to prevent race with selection clearing const stableRangeRef = useRef(lineRange); useEffect(() => { if (lineRange) { stableRangeRef.current = lineRange; } }, [lineRange]); const normalizeRange = (range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => { if (!range) return undefined; const start = Math.min(range.start, range.end); const end = Math.max(range.start, range.end); return { ...range, start, end }; }; const displayRange = normalizeRange(lineRange); // Focus on mount (desktop only) or when becoming visible useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; resizeTextarea(); const scrollContainer = textarea.closest('.overlay-scrollbar-container'); const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY; const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX; if (isMobile) { textarea.scrollIntoView({ behavior: 'auto', block: 'nearest' }); try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } return; } try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } const len = textarea.value.length; try { textarea.setSelectionRange(len, len); } catch (err) { void err; } requestAnimationFrame(() => { if (scrollContainer) { scrollContainer.scrollTop = prevScrollTop; scrollContainer.scrollLeft = prevScrollLeft; } else { window.scrollTo({ top: prevScrollTop, left: prevScrollLeft }); } }); }, [isMobile]); const save = () => { if (text.trim()) { onSave(text, normalizeRange(stableRangeRef.current)); } }; const handleKeyDown = (e: React.KeyboardEvent) => { // As the placeholder promises: Cmd/Ctrl+Enter attaches, plain Enter // breaks the line, Escape cancels. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); save(); } else if (e.key === 'Escape') { e.preventDefault(); onCancel(); } }; const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => { // Stop propagation to prevent parent selection clearing before save e.stopPropagation(); save(); }; return (
e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} >
{(fileLabel || displayRange) ? (
{fileLabel ? {fileLabel} : null} {fileLabel && displayRange ? : null} {displayRange ? ( {t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })} ) : null}
) : null}