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:
@@ -82,6 +82,27 @@ const PIERRE_RUNTIME_BASE_CSS = `
|
||||
const WEBKIT_SCROLL_FIX_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 {
|
||||
--diffs-bg-separator-override: var(--surface-elevated);
|
||||
}
|
||||
@@ -598,6 +619,187 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
}
|
||||
}, [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 lineType =
|
||||
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),
|
||||
disableFileHeader: true,
|
||||
enableLineSelection: enableComments,
|
||||
enableHoverUtility: false,
|
||||
enableGutterUtility: enableComments,
|
||||
onGutterUtilityClick: enableComments ? handleGutterUtilityClick : undefined,
|
||||
onLineClick: enableComments ? handleLineClick : undefined,
|
||||
onLineSelected: enableComments ? handleSelectionChange : undefined,
|
||||
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user