feat(diff): unified comment UI with gutter plus and content-drag selection
The diff comment editor is restyled as the shared comment pill (context line inside the box, auto-growing textarea, round attach button; Cmd/Ctrl+Enter attaches, Enter breaks the line) and the saved-comment card matches the composer preview entries. Pierre's gutter utility is enabled: hovering a line shows a small primary-colored plus that opens a comment for the line, dragging from it selects a range, and it rides the bottom of an active selection. Clicking a diff line toggles a single-line comment, and dragging over content maps to the same line selection the number column produces — the native text selection is suppressed once a drag crosses a line boundary — opening the editor for the range on release. Placeholders and meta lines are dimmed relative to typed text.
This commit is contained in:
@@ -616,7 +616,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
}}
|
}}
|
||||||
placeholder={t('chat.textSelection.comment.placeholder')}
|
placeholder={t('chat.textSelection.comment.placeholder')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]',
|
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
|
||||||
// The width cap sizes the floating desktop pill; on mobile the pill
|
// The width cap sizes the floating desktop pill; on mobile the pill
|
||||||
// spans the bottom bar and the cap would strand slack space to the
|
// spans the bottom bar and the cap would strand slack space to the
|
||||||
// right of the attach button.
|
// right of the attach button.
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface InlineCommentCardProps {
|
interface InlineCommentCardProps {
|
||||||
@@ -22,6 +14,14 @@ interface InlineCommentCardProps {
|
|||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEADER_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A saved inline comment shown under its lines in the diff/editor. Styled to
|
||||||
|
* match the composer's context preview entries: a muted header band naming
|
||||||
|
* the file and range with direct edit/remove actions, and the comment text
|
||||||
|
* below.
|
||||||
|
*/
|
||||||
export function InlineCommentCard({
|
export function InlineCommentCard({
|
||||||
draft,
|
draft,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -30,115 +30,90 @@ export function InlineCommentCard({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
}: InlineCommentCardProps) {
|
}: InlineCommentCardProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const themeContext = useOptionalThemeSystem();
|
|
||||||
const currentTheme = themeContext?.currentTheme;
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
const draftText = draft.text;
|
||||||
const draftText = typeof draft.text === 'string' ? draft.text : '';
|
|
||||||
|
|
||||||
// Check if content is long enough to warrant collapsing (rough estimate)
|
// Check if content is long enough to warrant collapsing (rough estimate)
|
||||||
// In a real app we might measure line height, but length check is a good proxy for now
|
|
||||||
const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3;
|
const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
|
<div
|
||||||
<ContextMenuTrigger
|
className={cn(
|
||||||
render={
|
'w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-none',
|
||||||
<div
|
className
|
||||||
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 transition-all duration-200",
|
style={{
|
||||||
className
|
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
||||||
)}
|
}}
|
||||||
style={{
|
data-comment-card="true"
|
||||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
>
|
||||||
borderColor: currentTheme?.colors?.interactive?.border,
|
<div
|
||||||
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
className="flex items-center gap-1.5 px-3 py-1.5"
|
||||||
}}
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}
|
||||||
data-comment-card="true"
|
|
||||||
onContextMenu={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsContextMenuOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 p-3">
|
<span className="min-w-0 max-w-[200px] truncate text-xs font-medium text-[var(--surface-foreground)]" title={draft.fileLabel}>
|
||||||
<div className="flex-1 min-w-0">
|
{draft.fileLabel}
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-1.5">
|
</span>
|
||||||
<span className="truncate max-w-[200px]" title={draft.fileLabel}>
|
<span className="text-xs text-[var(--surface-mutedForeground)]">•</span>
|
||||||
{draft.fileLabel}
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--surface-mutedForeground)]">
|
||||||
</span>
|
{t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })}
|
||||||
<span>•</span>
|
{draft.side ? ` (${draft.side})` : ''}
|
||||||
<span>{t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })}</span>
|
</span>
|
||||||
{draft.side && <span>({draft.side})</span>}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={HEADER_ACTION_CLASS}
|
||||||
|
style={{ minHeight: 0, minWidth: 0 }}
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label={t('inlineComment.actions.editComment')}
|
||||||
|
title={t('inlineComment.actions.editComment')}
|
||||||
|
>
|
||||||
|
<Icon name="pencil" className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={HEADER_ACTION_CLASS}
|
||||||
|
style={{ minHeight: 0, minWidth: 0 }}
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label={t('inlineComment.actions.deleteComment')}
|
||||||
|
title={t('inlineComment.actions.deleteComment')}
|
||||||
|
>
|
||||||
|
<Icon name="close" className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
|
||||||
|
<div className={cn('whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--surface-foreground)]', !isOpen && isLongContent && 'line-clamp-3')}>
|
||||||
|
{draftText}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
|
{isLongContent && (
|
||||||
<div className={cn("text-sm whitespace-pre-wrap break-words leading-relaxed", !isOpen && isLongContent && "line-clamp-3")}>
|
<CollapsibleTrigger asChild>
|
||||||
{draftText}
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1 h-6 w-full justify-start px-0 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<>
|
||||||
|
<Icon name="arrow-up-s" className="mr-1 size-3" />
|
||||||
|
{t('inlineComment.actions.showLess')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="arrow-down-s" className="mr-1 size-3" />
|
||||||
|
{t('inlineComment.actions.showMore')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLongContent && (
|
<CollapsibleContent>
|
||||||
<CollapsibleTrigger asChild>
|
{/* Used for animation purposes if we want to animate height */}
|
||||||
<Button
|
</CollapsibleContent>
|
||||||
variant="ghost"
|
</Collapsible>
|
||||||
size="sm"
|
|
||||||
className="h-6 px-0 mt-1 text-xs text-muted-foreground hover:text-foreground w-full justify-start"
|
|
||||||
>
|
|
||||||
{isOpen ? (
|
|
||||||
<>
|
|
||||||
<Icon name="arrow-up-s" className="size-3 mr-1" />
|
|
||||||
{t('inlineComment.actions.showLess')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="arrow-down-s" className="size-3 mr-1" />
|
|
||||||
{t('inlineComment.actions.showMore')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
{/* Used for animation purposes if we want to animate height */}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 -mr-1 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Icon name="more" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={onEdit}>
|
|
||||||
<Icon name="edit" className="size-4 mr-2" />
|
|
||||||
{t('inlineComment.actions.editComment')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
|
||||||
<Icon name="delete-bin" className="size-4 mr-2" />
|
|
||||||
{t('inlineComment.actions.deleteComment')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</div>
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem onClick={onEdit}>
|
|
||||||
<Icon name="edit" className="size-4 mr-2" />
|
|
||||||
{t('inlineComment.actions.editComment')}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
|
||||||
<Icon name="delete-bin" className="size-4 mr-2" />
|
|
||||||
{t('inlineComment.actions.deleteComment')}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
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 { cn } from '@/lib/utils';
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -18,6 +16,12 @@ export interface InlineCommentInputProps {
|
|||||||
maxWidth?: number;
|
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({
|
export function InlineCommentInput({
|
||||||
initialText = '',
|
initialText = '',
|
||||||
onTextChange,
|
onTextChange,
|
||||||
@@ -30,15 +34,22 @@ export function InlineCommentInput({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
}: InlineCommentInputProps) {
|
}: InlineCommentInputProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const themeContext = useOptionalThemeSystem();
|
|
||||||
const currentTheme = themeContext?.currentTheme;
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const [text, setText] = React.useState(initialText);
|
const [text, setText] = React.useState(initialText);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
void isEditing;
|
||||||
|
|
||||||
const handleTextChange = (value: string) => {
|
const handleTextChange = (value: string) => {
|
||||||
setText(value);
|
setText(value);
|
||||||
onTextChange?.(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
|
// Stable range snapshot to prevent race with selection clearing
|
||||||
@@ -62,8 +73,9 @@ export function InlineCommentInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
resizeTextarea();
|
||||||
|
|
||||||
const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
const scrollContainer = textarea.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||||
const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY;
|
const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY;
|
||||||
const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX;
|
const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX;
|
||||||
|
|
||||||
@@ -100,12 +112,18 @@ export function InlineCommentInput({
|
|||||||
});
|
});
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (text.trim()) {
|
||||||
|
onSave(text, normalizeRange(stableRangeRef.current));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
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') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (text.trim()) {
|
save();
|
||||||
onSave(text, normalizeRange(stableRangeRef.current));
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCancel();
|
onCancel();
|
||||||
@@ -115,75 +133,61 @@ export function InlineCommentInput({
|
|||||||
const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
|
const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
|
||||||
// Stop propagation to prevent parent selection clearing before save
|
// Stop propagation to prevent parent selection clearing before save
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (text.trim()) {
|
save();
|
||||||
onSave(text, normalizeRange(stableRangeRef.current));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
|
||||||
borderColor: currentTheme?.colors?.interactive?.border,
|
|
||||||
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
||||||
}}
|
}}
|
||||||
data-comment-input="true"
|
data-comment-input="true"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="p-3">
|
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
|
||||||
{(fileLabel || lineRange) && (
|
{(fileLabel || displayRange) ? (
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
|
<div className="flex items-center gap-2 px-3 pt-2 text-xs font-medium text-[var(--surface-mutedForeground)] opacity-60">
|
||||||
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
|
{fileLabel ? <span className="max-w-[200px] truncate">{fileLabel}</span> : null}
|
||||||
{fileLabel && lineRange && <span>•</span>}
|
{fileLabel && displayRange ? <span>•</span> : null}
|
||||||
{displayRange && (
|
{displayRange ? (
|
||||||
<span>
|
<span>{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}</span>
|
||||||
{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}
|
) : null}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
<div className="flex items-end gap-2 py-1 pl-3 pr-1">
|
||||||
<Textarea
|
<textarea
|
||||||
simple
|
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
rows={1}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => handleTextChange(e.target.value)}
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')}
|
placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')}
|
||||||
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={cn(
|
||||||
className="min-h-[80px] px-3 py-2.5 text-sm resize-y"
|
'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
|
||||||
<div className="flex items-center justify-end gap-2 mt-3">
|
type="button"
|
||||||
<Button
|
onClick={handleSaveClick}
|
||||||
variant="ghost"
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
size="sm"
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
onClick={onCancel}
|
disabled={!text.trim()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
className={cn(
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
'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',
|
||||||
className="h-8 text-muted-foreground hover:text-foreground"
|
isMobile ? 'h-9 w-9' : 'h-8 w-8'
|
||||||
>
|
)}
|
||||||
{t('inlineComment.actions.cancel')}
|
aria-label={t('inlineComment.actions.comment')}
|
||||||
</Button>
|
title={t('inlineComment.actions.comment')}
|
||||||
<Button
|
>
|
||||||
size="sm"
|
<Icon name="attachment-2" className="h-4 w-4" />
|
||||||
onClick={handleSaveClick}
|
</button>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,6 +82,27 @@ const PIERRE_RUNTIME_BASE_CSS = `
|
|||||||
const WEBKIT_SCROLL_FIX_CSS = `
|
const WEBKIT_SCROLL_FIX_CSS = `
|
||||||
${PIERRE_RUNTIME_BASE_CSS}
|
${PIERRE_RUNTIME_BASE_CSS}
|
||||||
|
|
||||||
|
/* While a multi-line content drag is being mapped to a line selection the
|
||||||
|
row highlight is the feedback; the native blue text selection on top of
|
||||||
|
it reads as double-selection, so it is painted transparent for the drag's
|
||||||
|
duration only (single-line selections keep the normal look for copying). */
|
||||||
|
:host([data-oc-comment-drag]) {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gutter "+" comment utility: theme primary, and smaller than Pierre's
|
||||||
|
1lh default, which reads oversized next to our 13px line numbers. */
|
||||||
|
[data-utility-button] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
align-self: center;
|
||||||
|
margin-right: calc(-16px + 1ch);
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: var(--primary-base);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
--diffs-bg-separator-override: var(--surface-elevated);
|
--diffs-bg-separator-override: var(--surface-elevated);
|
||||||
}
|
}
|
||||||
@@ -598,6 +619,187 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
}
|
}
|
||||||
}, [setSelection]);
|
}, [setSelection]);
|
||||||
|
|
||||||
|
// Multi-line text selection over diff CONTENT highlights the same line
|
||||||
|
// range Pierre paints for number-column selection — without opening the
|
||||||
|
// comment editor. The "+" utility then targets the highlighted range.
|
||||||
|
const contentSelectionRef = useRef<SelectedLineRange | null>(null);
|
||||||
|
const contentSelectionClearTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableComments) return;
|
||||||
|
const root = diffRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const getShadowRoot = (): ShadowRoot | null => {
|
||||||
|
const host = root.querySelector('diffs-container');
|
||||||
|
return host instanceof HTMLElement ? host.shadowRoot : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDragAttribute = (active: boolean) => {
|
||||||
|
const host = root.querySelector('diffs-container');
|
||||||
|
if (!(host instanceof HTMLElement)) return;
|
||||||
|
if (active) host.setAttribute('data-oc-comment-drag', '');
|
||||||
|
else host.removeAttribute('data-oc-comment-drag');
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineFromPoint = (clientX: number, clientY: number): { line: number; side: AnnotationSide; numberColumn: boolean } | null => {
|
||||||
|
const shadowRoot = getShadowRoot();
|
||||||
|
const element = shadowRoot?.elementFromPoint(clientX, clientY) ?? document.elementFromPoint(clientX, clientY);
|
||||||
|
if (!(element instanceof Element)) return null;
|
||||||
|
const numberColumn = Boolean(element.closest('[data-column-number]'));
|
||||||
|
const row = element.closest('[data-line]');
|
||||||
|
if (!(row instanceof HTMLElement)) return null;
|
||||||
|
const line = Number.parseInt(row.getAttribute('data-line') ?? '', 10);
|
||||||
|
if (!Number.isFinite(line) || line <= 0) return null;
|
||||||
|
const side: AnnotationSide = row.getAttribute('data-line-type') === 'change-deletion'
|
||||||
|
|| row.closest('[data-code][data-deletions]') != null
|
||||||
|
? 'deletions'
|
||||||
|
: 'additions';
|
||||||
|
return { line, side, numberColumn };
|
||||||
|
};
|
||||||
|
|
||||||
|
let anchor: { line: number; side: AnnotationSide } | null = null;
|
||||||
|
let engaged = false;
|
||||||
|
let pointerId: number | null = null;
|
||||||
|
|
||||||
|
const highlight = (range: SelectedLineRange) => {
|
||||||
|
contentSelectionRef.current = range;
|
||||||
|
const instance = diffInstanceRef.current;
|
||||||
|
if (!instance) return;
|
||||||
|
try {
|
||||||
|
isApplyingSelectionRef.current = true;
|
||||||
|
instance.setSelectedLines(range);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
isApplyingSelectionRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.button !== 0 || event.pointerType !== 'mouse') return;
|
||||||
|
const hit = lineFromPoint(event.clientX, event.clientY);
|
||||||
|
// Number-column drags belong to Pierre's own selection handling.
|
||||||
|
if (!hit || hit.numberColumn) {
|
||||||
|
anchor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
anchor = { line: hit.line, side: hit.side };
|
||||||
|
engaged = false;
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (anchor == null || event.pointerId !== pointerId) return;
|
||||||
|
const hit = lineFromPoint(event.clientX, event.clientY);
|
||||||
|
if (!hit) return;
|
||||||
|
if (!engaged) {
|
||||||
|
if (hit.line === anchor.line) return;
|
||||||
|
// The drag crossed into another line: from here it is a line
|
||||||
|
// selection, not a text selection. Drop the native selection and
|
||||||
|
// block new one from forming for the rest of the drag.
|
||||||
|
engaged = true;
|
||||||
|
setDragAttribute(true);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
const shadowRoot = getShadowRoot();
|
||||||
|
if (shadowRoot && 'getSelection' in shadowRoot) {
|
||||||
|
// SAFETY: getSelection on ShadowRoot is a Chromium extension absent
|
||||||
|
// from lib.dom; the `in` check gates the call.
|
||||||
|
(shadowRoot as ShadowRoot & { getSelection: () => Selection | null }).getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
highlight({
|
||||||
|
start: Math.min(anchor.line, hit.line),
|
||||||
|
end: Math.max(anchor.line, hit.line),
|
||||||
|
side: anchor.side,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
if (anchor == null || event.pointerId !== pointerId) return;
|
||||||
|
const wasEngaged = engaged;
|
||||||
|
anchor = null;
|
||||||
|
engaged = false;
|
||||||
|
pointerId = null;
|
||||||
|
setDragAttribute(false);
|
||||||
|
if (!wasEngaged) return;
|
||||||
|
const range = contentSelectionRef.current;
|
||||||
|
contentSelectionRef.current = null;
|
||||||
|
if (!range) return;
|
||||||
|
// A half-written comment survives an accidental selection elsewhere.
|
||||||
|
if (selectionRef.current && commentTextRef.current.trim() && !editingDraftIdRef.current) return;
|
||||||
|
applySelection(range);
|
||||||
|
if (!editingDraftIdRef.current) {
|
||||||
|
setCommentText('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
document.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||||
|
document.addEventListener('pointerup', handlePointerUp);
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
document.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
document.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
setDragAttribute(false);
|
||||||
|
};
|
||||||
|
}, [applySelection, enableComments, setCommentText]);
|
||||||
|
|
||||||
|
// The gutter "+" utility: pressing it (or dragging from it) yields a line
|
||||||
|
// range; select it so the comment editor opens under the lines.
|
||||||
|
const handleGutterUtilityClick = useCallback((range: SelectedLineRange) => {
|
||||||
|
if (!enableComments) return;
|
||||||
|
// A content-drag highlight is the intended target when the pressed line
|
||||||
|
// falls inside it.
|
||||||
|
const highlighted = contentSelectionRef.current;
|
||||||
|
const withinHighlight = highlighted
|
||||||
|
&& range.start >= highlighted.start
|
||||||
|
&& range.end <= highlighted.end
|
||||||
|
&& (range.side == null || range.side === highlighted.side);
|
||||||
|
if (contentSelectionClearTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(contentSelectionClearTimerRef.current);
|
||||||
|
contentSelectionClearTimerRef.current = null;
|
||||||
|
}
|
||||||
|
contentSelectionRef.current = null;
|
||||||
|
applySelection(withinHighlight && highlighted ? highlighted : range);
|
||||||
|
if (!editingDraftIdRef.current) {
|
||||||
|
setCommentText('');
|
||||||
|
}
|
||||||
|
}, [applySelection, enableComments, setCommentText]);
|
||||||
|
|
||||||
|
// Clicking anywhere on a diff line (not only its number cell) toggles a
|
||||||
|
// single-line comment selection, matching the "+" utility's target.
|
||||||
|
const handleLineClick = useCallback((props: { lineNumber: number; annotationSide: AnnotationSide; numberColumn: boolean }) => {
|
||||||
|
if (!enableComments || props.numberColumn) return;
|
||||||
|
// Ignore when the user selected text on the way to this click (copying
|
||||||
|
// code must not pop the comment editor).
|
||||||
|
if (window.getSelection()?.toString().trim()) return;
|
||||||
|
const side: SelectedLineRange['side'] = props.annotationSide;
|
||||||
|
const range: SelectedLineRange = { start: props.lineNumber, end: props.lineNumber, side };
|
||||||
|
const current = selectionRef.current;
|
||||||
|
if (current && current.start === range.start && current.end === range.end && current.side === range.side) {
|
||||||
|
if (!commentTextRef.current.trim()) {
|
||||||
|
setSelection(null);
|
||||||
|
const instance = diffInstanceRef.current;
|
||||||
|
try {
|
||||||
|
isApplyingSelectionRef.current = true;
|
||||||
|
instance?.setSelectedLines(null);
|
||||||
|
} finally {
|
||||||
|
isApplyingSelectionRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current && commentTextRef.current.trim() && !editingDraftIdRef.current) {
|
||||||
|
// A half-written comment survives an accidental click elsewhere.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySelection(range);
|
||||||
|
if (!editingDraftIdRef.current) {
|
||||||
|
setCommentText('');
|
||||||
|
}
|
||||||
|
}, [applySelection, enableComments, setCommentText, setSelection]);
|
||||||
|
|
||||||
const resolveClickedSide = useCallback((numberCell: HTMLElement): AnnotationSide => {
|
const resolveClickedSide = useCallback((numberCell: HTMLElement): AnnotationSide => {
|
||||||
const lineType =
|
const lineType =
|
||||||
numberCell.closest('[data-line-type]')?.getAttribute('data-line-type')
|
numberCell.closest('[data-line-type]')?.getAttribute('data-line-type')
|
||||||
@@ -758,11 +960,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
||||||
disableFileHeader: true,
|
disableFileHeader: true,
|
||||||
enableLineSelection: enableComments,
|
enableLineSelection: enableComments,
|
||||||
enableHoverUtility: false,
|
enableGutterUtility: enableComments,
|
||||||
|
onGutterUtilityClick: enableComments ? handleGutterUtilityClick : undefined,
|
||||||
|
onLineClick: enableComments ? handleLineClick : undefined,
|
||||||
onLineSelected: enableComments ? handleSelectionChange : undefined,
|
onLineSelected: enableComments ? handleSelectionChange : undefined,
|
||||||
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
||||||
renderAnnotation: enableComments ? renderAnnotation : undefined,
|
renderAnnotation: enableComments ? renderAnnotation : undefined,
|
||||||
}), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
}), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]);
|
||||||
|
|
||||||
|
|
||||||
const lineAnnotations = useMemo(() => {
|
const lineAnnotations = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user