Files
openchamber/packages/ui/src/components/comments/InlineCommentInput.tsx
T

196 lines
6.5 KiB
TypeScript

import React, { useRef, useEffect } from 'react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
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;
}
export function InlineCommentInput({
initialText = '',
onTextChange,
onSave,
onCancel,
fileLabel,
lineRange,
isEditing = false,
className,
maxWidth,
}: InlineCommentInputProps) {
const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const saveShortcut = formatShortcutForDisplay('mod+enter');
const handleTextChange = (value: string) => {
setText(value);
onTextChange?.(value);
};
// 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;
const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null;
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 handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (text.trim()) {
onSave(text, normalizeRange(stableRangeRef.current));
}
} 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();
if (text.trim()) {
onSave(text, normalizeRange(stableRangeRef.current));
}
};
return (
<div
className={cn(
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden animate-in fade-in zoom-in-95 duration-200",
className
)}
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
}}
data-comment-input="true"
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<div className="p-3">
{(fileLabel || lineRange) && (
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
{fileLabel && lineRange && <span></span>}
{displayRange && (
<span>
{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}
</span>
)}
</div>
)}
<Textarea
simple
ref={textareaRef}
value={text}
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isMobile
? t('inlineComment.input.placeholderShort')
: t('inlineComment.input.placeholder', { shortcut: saveShortcut })}
outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]"
className="min-h-[80px] px-3 py-2.5 text-sm resize-y"
/>
<div className="flex items-center justify-end gap-2 mt-3">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="h-8 text-muted-foreground hover:text-foreground"
>
{t('inlineComment.actions.cancel')}
</Button>
<Button
size="sm"
onClick={handleSaveClick}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
disabled={!text.trim()}
className="h-8 min-w-[80px]"
style={{
backgroundColor: currentTheme?.colors?.status?.success,
color: currentTheme?.colors?.status?.successForeground,
}}
>
{isEditing ? t('inlineComment.actions.save') : t('inlineComment.actions.comment')}
</Button>
</div>
</div>
</div>
);
}