200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
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<HTMLTextAreaElement>(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<HTMLElement>('.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 (
|
|
<div
|
|
className={cn(
|
|
'w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] animate-in fade-in zoom-in-95 duration-200',
|
|
className
|
|
)}
|
|
style={{
|
|
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
|
}}
|
|
data-comment-input="true"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onTouchStart={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="oc-glass-popover rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
|
|
{(fileLabel || displayRange) ? (
|
|
<div className="flex items-center gap-2 px-3 pt-2 text-xs font-medium text-[var(--surface-mutedForeground)] opacity-60">
|
|
{fileLabel ? <span className="max-w-[200px] truncate">{fileLabel}</span> : null}
|
|
{fileLabel && displayRange ? <span>•</span> : null}
|
|
{displayRange ? (
|
|
<span>{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-end gap-2 py-1 pl-3 pr-1">
|
|
<textarea
|
|
ref={textareaRef}
|
|
rows={1}
|
|
value={text}
|
|
onChange={(e) => handleTextChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={isMobile
|
|
? t('inlineComment.input.placeholderShort')
|
|
: t('inlineComment.input.placeholder', { shortcut: saveShortcut })}
|
|
className={cn(
|
|
'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
|
|
isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5'
|
|
)}
|
|
style={{ minHeight: 0, height: 'auto' }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveClick}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onTouchStart={(e) => e.stopPropagation()}
|
|
disabled={!text.trim()}
|
|
className={cn(
|
|
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] transition-opacity duration-150 hover:opacity-90 disabled:opacity-40',
|
|
isMobile ? 'h-9 w-9' : 'h-8 w-8'
|
|
)}
|
|
aria-label={t('inlineComment.actions.comment')}
|
|
title={t('inlineComment.actions.comment')}
|
|
>
|
|
<Icon name="attachment-2" className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|