Restore embedded inline comments in Plan/File/Diff views (#456)
* fix(plan-comments): restore embedded inline comment widgets in Plan view Reinstate CodeMirror block-widget comments so plan annotations stay anchored to selected lines and preserve drag/selection behavior without floating overlays. * fix(file-comments): return Files editor comments to embedded widgets Use inline block widgets for file drafts while keeping full-path draft scoping, so similarly named files no longer risk comment collisions. * fix(diff-comments): render inline comments through annotation portals Replace absolute floating positioning with annotation-target portals to keep diff comments attached to their lines across shadow DOM updates. * chore(comments): remove deprecated floating comment hook Drop the unused floating-comment implementation now that plan, file, and diff views all use embedded comment rendering paths. * fix(codemirror): expose gutter width as CSS variable Measure the current CodeMirror gutter and publish --oc-editor-gutter-width on the editor host so inline widgets can size to the visible code area without hardcoded dimensions. * fix(context-panel): publish panel width for embedded widgets Set --oc-context-panel-width on the context panel in both docked and expanded modes so comment widgets can follow the active panel width dynamically. * fix(file-comments): constrain inline input to editor content width Use context-panel and editor-gutter CSS variables to cap comment input width to the visible editor content area, keeping action buttons fully visible in no-wrap mode. * fix(file-comments): constrain inline comment cards to content area Apply the same variable-based width cap to saved comment cards so card actions stay visible when long lines force horizontal scrolling. * fix(diff-comments): stabilize new comment annotation identity Derive new-comment annotation ids from selection side and line range, and reuse that id for portal target lookup and keys to avoid remount glitches.
This commit is contained in:
@@ -36,7 +36,7 @@ export function InlineCommentCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border shadow-sm w-full overflow-hidden transition-all duration-200",
|
||||
"rounded-lg border shadow-sm 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",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -112,7 +112,7 @@ export function InlineCommentInput({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border shadow-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
||||
"rounded-lg border shadow-sm 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={{
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { InlineCommentCard } from './InlineCommentCard';
|
||||
import { InlineCommentInput } from './InlineCommentInput';
|
||||
|
||||
type SelectedLineRange = { start: number; end: number };
|
||||
|
||||
type CommentPos = {
|
||||
top: number;
|
||||
flipUp: boolean;
|
||||
};
|
||||
|
||||
const COMMENT_POPOVER_HEIGHT = 200;
|
||||
|
||||
function getLineTop(view: EditorView, wrapper: HTMLElement, lineNumber: number, position: 'top' | 'bottom'): number | undefined {
|
||||
const lineCount = view.state.doc.lines;
|
||||
if (lineNumber < 1 || lineNumber > lineCount) return undefined;
|
||||
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const coords = view.coordsAtPos(line.from);
|
||||
if (!coords) return undefined;
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
if (position === 'bottom') {
|
||||
return coords.bottom - wrapperRect.top;
|
||||
}
|
||||
return coords.top - wrapperRect.top;
|
||||
}
|
||||
|
||||
function shouldFlipUp(view: EditorView, endLine: number, scrollContainer: HTMLElement | null): boolean {
|
||||
const lineCount = view.state.doc.lines;
|
||||
if (endLine < 1 || endLine > lineCount) return false;
|
||||
|
||||
const line = view.state.doc.line(endLine);
|
||||
const coords = view.coordsAtPos(line.from);
|
||||
if (!coords) return false;
|
||||
|
||||
const viewportBottom = scrollContainer
|
||||
? scrollContainer.getBoundingClientRect().bottom
|
||||
: window.innerHeight;
|
||||
|
||||
return (coords.bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom;
|
||||
}
|
||||
|
||||
function computePosition(
|
||||
view: EditorView,
|
||||
wrapper: HTMLElement,
|
||||
scrollContainer: HTMLElement | null,
|
||||
range: { start: number; end: number },
|
||||
): CommentPos | undefined {
|
||||
const flipUp = shouldFlipUp(view, range.end, scrollContainer);
|
||||
|
||||
const top = flipUp
|
||||
? getLineTop(view, wrapper, range.start, 'top')
|
||||
: getLineTop(view, wrapper, range.end, 'bottom');
|
||||
|
||||
if (top === undefined) return undefined;
|
||||
return { top, flipUp };
|
||||
}
|
||||
|
||||
type FloatingCommentsProps = {
|
||||
editorView: EditorView | null;
|
||||
wrapperRef: React.RefObject<HTMLElement | null>;
|
||||
fileDrafts: InlineCommentDraft[];
|
||||
editingDraftId: string | null;
|
||||
commentText: string;
|
||||
lineSelection: SelectedLineRange | null;
|
||||
isDragging: boolean;
|
||||
fileLabel: string;
|
||||
onSaveComment: (text: string, range?: SelectedLineRange) => void;
|
||||
onCancelComment: () => void;
|
||||
onEditDraft: (draft: InlineCommentDraft) => void;
|
||||
onDeleteDraft: (draft: InlineCommentDraft) => void;
|
||||
};
|
||||
|
||||
export function useFloatingComments({
|
||||
editorView,
|
||||
wrapperRef,
|
||||
fileDrafts,
|
||||
editingDraftId,
|
||||
commentText,
|
||||
lineSelection,
|
||||
isDragging,
|
||||
fileLabel,
|
||||
onSaveComment,
|
||||
onCancelComment,
|
||||
onEditDraft,
|
||||
onDeleteDraft,
|
||||
}: FloatingCommentsProps): React.ReactNode {
|
||||
const [positions, setPositions] = React.useState<Record<string, CommentPos | undefined>>({});
|
||||
|
||||
const updatePositions = React.useCallback(() => {
|
||||
const view = editorView;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!view || !wrapper) return;
|
||||
|
||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
||||
const next: Record<string, CommentPos | undefined> = {};
|
||||
|
||||
for (const d of fileDrafts) {
|
||||
next[d.id] = computePosition(view, wrapper, scrollContainer, {
|
||||
start: d.startLine,
|
||||
end: d.endLine,
|
||||
});
|
||||
}
|
||||
|
||||
if (lineSelection && !editingDraftId && !isDragging) {
|
||||
next['__new__'] = computePosition(view, wrapper, scrollContainer, {
|
||||
start: lineSelection.start,
|
||||
end: lineSelection.end,
|
||||
});
|
||||
}
|
||||
|
||||
setPositions(next);
|
||||
}, [editorView, wrapperRef, fileDrafts, editingDraftId, lineSelection, isDragging]);
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(updatePositions);
|
||||
}, [updatePositions]);
|
||||
|
||||
// Also update on scroll
|
||||
React.useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const onScroll = () => requestAnimationFrame(updatePositions);
|
||||
scrollContainer.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => scrollContainer.removeEventListener('scroll', onScroll);
|
||||
}, [wrapperRef, updatePositions]);
|
||||
|
||||
const popoverStyle = (flipUp: boolean): React.CSSProperties => flipUp
|
||||
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
||||
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
|
||||
|
||||
return (
|
||||
<>
|
||||
{fileDrafts.map((d) => {
|
||||
const pos = positions[d.id];
|
||||
if (!pos) return null;
|
||||
|
||||
if (d.id === editingDraftId) {
|
||||
return (
|
||||
<div
|
||||
key={`edit-${d.id}`}
|
||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div style={popoverStyle(pos.flipUp)}>
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={{ start: d.startLine, end: d.endLine }}
|
||||
isEditing={true}
|
||||
onSave={onSaveComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`saved-${d.id}`}
|
||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
|
||||
>
|
||||
<InlineCommentCard
|
||||
draft={d}
|
||||
onEdit={() => onEditDraft(d)}
|
||||
onDelete={() => onDeleteDraft(d)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{lineSelection && !editingDraftId && !isDragging && positions['__new__'] && (
|
||||
<div
|
||||
key="new-comment"
|
||||
style={{ position: 'absolute', right: 24, top: positions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div style={popoverStyle(positions['__new__'].flipUp)}>
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={lineSelection}
|
||||
isEditing={false}
|
||||
onSave={onSaveComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -194,6 +194,17 @@ export const ContextPanel: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = isExpanded
|
||||
? {
|
||||
['--oc-context-panel-width' as string]: '100vw',
|
||||
}
|
||||
: {
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
['--oc-context-panel-width' as string]: `${width}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
@@ -207,13 +218,7 @@ export const ContextPanel: React.FC = () => {
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
|
||||
)}
|
||||
onKeyDownCapture={handlePanelKeyDownCapture}
|
||||
style={isExpanded
|
||||
? undefined
|
||||
: {
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
}}
|
||||
style={panelStyle}
|
||||
>
|
||||
{!isExpanded && (
|
||||
<div
|
||||
|
||||
@@ -165,6 +165,18 @@ export function CodeMirrorEditor({
|
||||
// Scoped map for widget containers to avoid global collisions and memory leaks
|
||||
const widgetContainersRef = React.useRef(new Map<string, HTMLElement>());
|
||||
|
||||
const syncEditorCssVars = React.useCallback((view?: EditorView | null) => {
|
||||
const host = hostRef.current;
|
||||
const resolvedView = view ?? viewRef.current;
|
||||
if (!host || !resolvedView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gutters = resolvedView.dom.querySelector('.cm-gutters');
|
||||
const gutterWidth = gutters instanceof HTMLElement ? gutters.getBoundingClientRect().width : 0;
|
||||
host.style.setProperty('--oc-editor-gutter-width', `${gutterWidth}px`);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
valueRef.current = value;
|
||||
}, [value]);
|
||||
@@ -201,6 +213,7 @@ export function CodeMirrorEditor({
|
||||
indentUnit.of(' '),
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
syncEditorCssVars(update.view);
|
||||
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
|
||||
forceUpdate();
|
||||
}
|
||||
@@ -226,6 +239,7 @@ export function CodeMirrorEditor({
|
||||
|
||||
forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
|
||||
viewRef.current.requestMeasure();
|
||||
requestAnimationFrame(() => syncEditorCssVars(viewRef.current));
|
||||
|
||||
if (viewRef.current) {
|
||||
onViewReadyRef.current?.(viewRef.current);
|
||||
@@ -258,11 +272,12 @@ export function CodeMirrorEditor({
|
||||
|
||||
forceParsingCompat(view, view.state.doc.length, 200);
|
||||
view.requestMeasure();
|
||||
requestAnimationFrame(() => syncEditorCssVars(view));
|
||||
|
||||
// Force a re-render to ensure Portals can find the new widget containers in the DOM
|
||||
// The containers are created synchronously by CodeMirror during dispatch -> toDOM
|
||||
forceUpdate();
|
||||
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]);
|
||||
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch, syncEditorCssVars]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
|
||||
@@ -66,7 +66,8 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useFloatingComments } from '@/components/comments/useFloatingComments';
|
||||
import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
|
||||
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
@@ -1798,23 +1799,65 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path);
|
||||
}, [selectedFile, currentSessionId, allDrafts]);
|
||||
|
||||
const floatingComments = useFloatingComments({
|
||||
editorView: editorViewRef.current,
|
||||
wrapperRef: editorWrapperRef,
|
||||
fileDrafts: filesFileDrafts,
|
||||
editingDraftId,
|
||||
commentText: '',
|
||||
lineSelection,
|
||||
isDragging,
|
||||
fileLabel: selectedFile?.path ?? '',
|
||||
onSaveComment: handleSaveComment,
|
||||
onCancelComment: () => setLineSelection(null),
|
||||
onEditDraft: (draft) => {
|
||||
setEditingDraftId(draft.id);
|
||||
setLineSelection(null);
|
||||
},
|
||||
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
|
||||
});
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
const widgets: import('@/components/ui/CodeMirrorEditor').BlockWidgetDef[] = [];
|
||||
|
||||
for (const draft of filesFileDrafts) {
|
||||
if (draft.id === editingDraftId) {
|
||||
widgets.push({
|
||||
afterLine: draft.endLine,
|
||||
id: `edit-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
key={draft.id}
|
||||
initialText={draft.text}
|
||||
fileLabel={selectedFile?.path}
|
||||
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
||||
isEditing={true}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={() => setEditingDraftId(null)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
widgets.push({
|
||||
afterLine: draft.endLine,
|
||||
id: `card-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentCard
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
onEdit={() => {
|
||||
setEditingDraftId(draft.id);
|
||||
setLineSelection(null);
|
||||
}}
|
||||
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineSelection && !editingDraftId && !isDragging) {
|
||||
widgets.push({
|
||||
afterLine: lineSelection.end,
|
||||
id: 'files-new-comment-input',
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
key="new-comment"
|
||||
initialText=""
|
||||
fileLabel={selectedFile?.path}
|
||||
lineRange={lineSelection}
|
||||
isEditing={false}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={() => setLineSelection(null)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}, [filesFileDrafts, editingDraftId, lineSelection, isDragging, selectedFile?.path, handleSaveComment, removeDraft]);
|
||||
|
||||
const fileViewer = (
|
||||
<div
|
||||
@@ -2184,6 +2227,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
onChange={setDraftContent}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
blockWidgets={blockWidgets}
|
||||
onViewReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
window.requestAnimationFrame(() => {
|
||||
@@ -2269,7 +2313,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{floatingComments}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
// createPortal no longer needed — comments float absolutely outside shadow DOM
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
FileDiff as PierreFileDiff,
|
||||
VirtualizedFileDiff,
|
||||
@@ -275,115 +275,48 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
if (meta.type === 'saved' || meta.type === 'edit') {
|
||||
return `draft-${meta.draft.id}`;
|
||||
} else if (meta.type === 'new') {
|
||||
return 'new-comment-input';
|
||||
const start = Math.min(meta.selection.start, meta.selection.end);
|
||||
const end = Math.max(meta.selection.start, meta.selection.end);
|
||||
const side = meta.selection.side ?? 'additions';
|
||||
return `new-comment-${side}-${start}-${end}`;
|
||||
}
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
|
||||
const div = document.createElement('div');
|
||||
// Invisible — comments are rendered as floating elements outside shadow DOM
|
||||
div.style.display = 'none';
|
||||
div.style.position = 'relative';
|
||||
|
||||
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
|
||||
const id = getAnnotationId(meta);
|
||||
|
||||
|
||||
div.dataset.annotationId = id;
|
||||
div.dataset.annotationSide = annotation.side;
|
||||
div.dataset.annotationLine = String(annotation.lineNumber);
|
||||
return div;
|
||||
}, [getAnnotationId]);
|
||||
|
||||
// Compute floating comment positions by finding target lines in Pierre's shadow DOM
|
||||
const findLineElement = useCallback((root: ShadowRoot, line: number, side?: string) => {
|
||||
const nodes = Array.from(
|
||||
root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)
|
||||
).filter((n): n is HTMLElement => n instanceof HTMLElement);
|
||||
if (nodes.length === 0) return undefined;
|
||||
if (!side) return nodes[0];
|
||||
const match = nodes.find((n) => {
|
||||
const lineType = n.closest('[data-line-type]')?.getAttribute('data-line-type') ?? n.getAttribute('data-line-type');
|
||||
if (side === 'deletions') return lineType === 'change-deletion';
|
||||
return lineType !== 'change-deletion';
|
||||
});
|
||||
return match ?? nodes[0];
|
||||
}, []);
|
||||
const annotationTargetsRef = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
const getAnchorPositions = useCallback((wrapper: HTMLElement, root: ShadowRoot, range: { start: number; end: number; side?: string }) => {
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const first = findLineElement(root, range.start, range.side);
|
||||
const last = findLineElement(root, range.end, range.side);
|
||||
|
||||
// Bottom of last line (for below placement)
|
||||
const lastEl = last ?? first;
|
||||
const bottomTop = lastEl
|
||||
? lastEl.getBoundingClientRect().top - wrapperRect.top + lastEl.getBoundingClientRect().height
|
||||
: undefined;
|
||||
|
||||
// Top of first line (for above placement)
|
||||
const firstEl = first ?? last;
|
||||
const aboveTop = firstEl
|
||||
? firstEl.getBoundingClientRect().top - wrapperRect.top
|
||||
: undefined;
|
||||
|
||||
return { bottomTop, aboveTop };
|
||||
}, [findLineElement]);
|
||||
|
||||
const [commentPositions, setCommentPositions] = useState<Record<string, { top: number; flipUp: boolean } | undefined>>({});
|
||||
type CommentPos = { top: number; flipUp: boolean };
|
||||
|
||||
const COMMENT_POPOVER_HEIGHT = 200; // approximate height of comment popover
|
||||
|
||||
const updateCommentPositions = useCallback(() => {
|
||||
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
|
||||
const wrapper = diffRootRef.current;
|
||||
if (!wrapper) return;
|
||||
if (!wrapper) return null;
|
||||
|
||||
const host = wrapper.querySelector('diffs-container') ?? diffContainerRef.current?.querySelector('diffs-container');
|
||||
const shadow = (host as HTMLElement | null)?.shadowRoot;
|
||||
if (!shadow) return;
|
||||
|
||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
||||
const viewportBottom = scrollContainer
|
||||
? scrollContainer.getBoundingClientRect().bottom
|
||||
: window.innerHeight;
|
||||
|
||||
const computePos = (range: { start: number; end: number; side?: string }): CommentPos | undefined => {
|
||||
const anchors = getAnchorPositions(wrapper, shadow, range);
|
||||
if (anchors.bottomTop === undefined) return undefined;
|
||||
|
||||
// Check if placing below last line would overflow viewport
|
||||
const lastEl = findLineElement(shadow, range.end, range.side) ?? findLineElement(shadow, range.start, range.side);
|
||||
const flipUp = lastEl
|
||||
? (lastEl.getBoundingClientRect().bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom
|
||||
: false;
|
||||
|
||||
return {
|
||||
top: flipUp ? (anchors.aboveTop ?? anchors.bottomTop) : anchors.bottomTop,
|
||||
flipUp,
|
||||
};
|
||||
};
|
||||
|
||||
const next: Record<string, CommentPos | undefined> = {};
|
||||
const sessionKey = getSessionKey();
|
||||
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
|
||||
const fileLabel = fileName || 'unknown';
|
||||
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
||||
|
||||
for (const d of fileDrafts) {
|
||||
const side = d.side === 'original' ? 'deletions' : 'additions';
|
||||
next[d.id] = computePos({ start: d.startLine, end: d.endLine, side });
|
||||
const cached = annotationTargetsRef.current[id];
|
||||
if (cached && wrapper.contains(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (selection && !editingDraftId) {
|
||||
const side = selection.side ?? 'additions';
|
||||
next['__new__'] = computePos({ start: selection.start, end: selection.end, side });
|
||||
}
|
||||
const host = wrapper.querySelector('diffs-container');
|
||||
if (!host) return null;
|
||||
|
||||
setCommentPositions(next);
|
||||
}, [allDrafts, editingDraftId, fileName, findLineElement, getAnchorPositions, getSessionKey, selection]);
|
||||
const shadowRoot = host.shadowRoot;
|
||||
if (!shadowRoot) return null;
|
||||
|
||||
const updateCommentPositionsRef = useRef(updateCommentPositions);
|
||||
useEffect(() => {
|
||||
updateCommentPositionsRef.current = updateCommentPositions;
|
||||
}, [updateCommentPositions]);
|
||||
const target = shadowRoot.querySelector(`[data-annotation-id="${id}"]`) as HTMLElement | null;
|
||||
annotationTargetsRef.current[id] = target;
|
||||
return target;
|
||||
}, []);
|
||||
|
||||
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
|
||||
// Use provided range override or fall back to current selection
|
||||
@@ -667,10 +600,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
containerWrapper: container,
|
||||
});
|
||||
|
||||
// Update floating comment positions after Pierre renders
|
||||
requestAnimationFrame(() => {
|
||||
forceUpdate();
|
||||
updateCommentPositionsRef.current();
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -699,9 +630,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
void err;
|
||||
}
|
||||
forceUpdate();
|
||||
updateCommentPositions();
|
||||
});
|
||||
}, [lineAnnotations, updateCommentPositions]);
|
||||
}, [lineAnnotations]);
|
||||
|
||||
useEffect(() => {
|
||||
const instance = diffInstanceRef.current;
|
||||
@@ -788,10 +718,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
};
|
||||
}, [diffThemeKey, fileName, handleSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(updateCommentPositions);
|
||||
}, [selection, editingDraftId, allDrafts, updateCommentPositions]);
|
||||
|
||||
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
|
||||
useEffect(() => {
|
||||
const container = diffContainerRef.current;
|
||||
@@ -804,35 +730,20 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
const diffsContainer = container.querySelector('diffs-container');
|
||||
if (!diffsContainer) return;
|
||||
|
||||
// Watch for annotation nodes being added/removed
|
||||
observer = new MutationObserver((mutations) => {
|
||||
const hasAnnotationChanges = mutations.some(m =>
|
||||
Array.from(m.addedNodes).some(n =>
|
||||
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
|
||||
) ||
|
||||
Array.from(m.removedNodes).some(n =>
|
||||
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
|
||||
)
|
||||
);
|
||||
const shadowRoot = diffsContainer.shadowRoot;
|
||||
if (!shadowRoot) return;
|
||||
|
||||
if (hasAnnotationChanges) {
|
||||
// Debounce with RAF to batch multiple mutations
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
forceUpdate();
|
||||
rafId = null;
|
||||
});
|
||||
}
|
||||
observer = new MutationObserver(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
forceUpdate();
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
// Observe both shadow root and light DOM
|
||||
if (diffsContainer.shadowRoot) {
|
||||
observer.observe(diffsContainer.shadowRoot, { childList: true, subtree: true });
|
||||
}
|
||||
observer.observe(diffsContainer, { childList: true, subtree: true });
|
||||
observer.observe(shadowRoot, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
// Delay setup to allow Pierre to initialize
|
||||
const timeoutId = setTimeout(setupObserver, 100);
|
||||
|
||||
return () => {
|
||||
@@ -840,91 +751,76 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [diffThemeKey, fileName]); // Re-setup when diff changes
|
||||
}, [diffThemeKey, fileName]);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Floating comment elements positioned absolutely over the diff
|
||||
const sessionKey = getSessionKey();
|
||||
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
|
||||
const fileLabel = fileName || 'unknown';
|
||||
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
||||
|
||||
const floatingComments = (
|
||||
const commentPortals = (
|
||||
<>
|
||||
{fileDrafts.map((d) => {
|
||||
const pos = commentPositions[d.id];
|
||||
if (!pos) return null;
|
||||
|
||||
const popoverStyle: React.CSSProperties = pos.flipUp
|
||||
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
||||
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
|
||||
const target = resolveAnnotationTarget(`draft-${d.id}`);
|
||||
if (!target) return null;
|
||||
|
||||
if (d.id === editingDraftId) {
|
||||
return (
|
||||
<div
|
||||
key={`edit-${d.id}`}
|
||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div style={popoverStyle}>
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||
lineRange={{
|
||||
start: d.startLine,
|
||||
end: d.endLine,
|
||||
side: d.side === 'original' ? 'deletions' : 'additions'
|
||||
}}
|
||||
isEditing={true}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`saved-${d.id}`}
|
||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
|
||||
>
|
||||
<InlineCommentCard
|
||||
draft={d}
|
||||
onEdit={() => {
|
||||
const side = d.side === 'original' ? 'deletions' : 'additions';
|
||||
applySelection({ start: d.startLine, end: d.endLine, side });
|
||||
setCommentText(d.text);
|
||||
setEditingDraftId(d.id);
|
||||
}}
|
||||
onDelete={() => removeDraft(d.sessionKey, d.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{selection && !editingDraftId && commentPositions['__new__'] && (
|
||||
<div
|
||||
key="new-comment"
|
||||
style={{ position: 'absolute', right: 24, top: commentPositions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div style={commentPositions['__new__'].flipUp
|
||||
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
||||
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
||||
}>
|
||||
return createPortal(
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||
lineRange={selection || undefined}
|
||||
isEditing={false}
|
||||
lineRange={{
|
||||
start: d.startLine,
|
||||
end: d.endLine,
|
||||
side: d.side === 'original' ? 'deletions' : 'additions'
|
||||
}}
|
||||
isEditing={true}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>,
|
||||
target,
|
||||
`draft-edit-${d.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<InlineCommentCard
|
||||
draft={d}
|
||||
onEdit={() => {
|
||||
const side = d.side === 'original' ? 'deletions' : 'additions';
|
||||
applySelection({ start: d.startLine, end: d.endLine, side });
|
||||
setCommentText(d.text);
|
||||
setEditingDraftId(d.id);
|
||||
}}
|
||||
onDelete={() => removeDraft(d.sessionKey, d.id)}
|
||||
/>,
|
||||
target,
|
||||
`draft-card-${d.id}`
|
||||
);
|
||||
})}
|
||||
|
||||
{selection && !editingDraftId && (() => {
|
||||
const newCommentAnnotationId = getAnnotationId({ type: 'new', selection });
|
||||
const target = resolveAnnotationTarget(newCommentAnnotationId);
|
||||
if (!target) return null;
|
||||
|
||||
return createPortal(
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||
lineRange={selection || undefined}
|
||||
isEditing={false}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>,
|
||||
target,
|
||||
newCommentAnnotationId
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -940,9 +836,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
>
|
||||
<div ref={diffRootRef} className="size-full relative">
|
||||
<div ref={diffContainerRef} className="size-full" />
|
||||
{floatingComments}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
{commentPortals}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -953,8 +849,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
<div className={cn("relative", "w-full")}>
|
||||
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
||||
<div ref={diffContainerRef} className="w-full" />
|
||||
{floatingComments}
|
||||
</div>
|
||||
{commentPortals}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { useFloatingComments } from '@/components/comments/useFloatingComments';
|
||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
|
||||
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
|
||||
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
@@ -389,24 +390,55 @@ export const PlanView: React.FC = () => {
|
||||
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
|
||||
}, [getSessionKey, allDrafts, planFileLabel]);
|
||||
|
||||
const floatingComments = useFloatingComments({
|
||||
editorView: editorViewRef.current,
|
||||
wrapperRef: editorWrapperRef,
|
||||
fileDrafts: planFileDrafts,
|
||||
editingDraftId,
|
||||
commentText,
|
||||
lineSelection,
|
||||
isDragging,
|
||||
fileLabel: planFileLabel,
|
||||
onSaveComment: handleSaveComment,
|
||||
onCancelComment: handleCancelComment,
|
||||
onEditDraft: (draft) => {
|
||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
},
|
||||
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
|
||||
});
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
const widgets: Array<{ afterLine: number; id: string; content: React.ReactNode }> = [];
|
||||
|
||||
for (const draft of planFileDrafts) {
|
||||
const content = draft.id === editingDraftId ? (
|
||||
<InlineCommentInput
|
||||
key={`edit-${draft.id}`}
|
||||
initialText={commentText}
|
||||
fileLabel={draft.fileLabel}
|
||||
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
||||
isEditing={true}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>
|
||||
) : (
|
||||
<InlineCommentCard
|
||||
key={`saved-${draft.id}`}
|
||||
draft={draft}
|
||||
onEdit={() => {
|
||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
}}
|
||||
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
/>
|
||||
);
|
||||
widgets.push({ afterLine: draft.endLine, id: draft.id, content });
|
||||
}
|
||||
|
||||
if (lineSelection && !editingDraftId && !isDragging) {
|
||||
widgets.push({
|
||||
afterLine: Math.max(lineSelection.start, lineSelection.end),
|
||||
id: 'plan-new-comment-input',
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
key="new-comment"
|
||||
initialText={commentText}
|
||||
fileLabel={planFileLabel}
|
||||
lineRange={lineSelection}
|
||||
isEditing={false}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}, [planFileDrafts, editingDraftId, commentText, lineSelection, isDragging, planFileLabel, handleSaveComment, handleCancelComment, removeDraft]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
|
||||
@@ -489,103 +521,95 @@ export const PlanView: React.FC = () => {
|
||||
<div className="p-3 typography-ui text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
['--oc-plan-comment-pad' as string]: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full oc-plan-editor">
|
||||
{mdViewMode === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-3">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
||||
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Switch to edit mode to fix the issue.
|
||||
</div>
|
||||
<div className="h-full oc-plan-editor">
|
||||
{mdViewMode === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-3">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
||||
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Switch to edit mode to fix the issue.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-full" ref={editorWrapperRef}>
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={() => {
|
||||
// read-only
|
||||
}}
|
||||
readOnly={true}
|
||||
className="h-full"
|
||||
extensions={editorExtensions}
|
||||
onViewReady={(view) => { editorViewRef.current = view; }}
|
||||
onViewDestroy={() => { editorViewRef.current = null; }}
|
||||
blockWidgets={blockWidgets}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
end: Math.max(lineSelection.start, lineSelection.end),
|
||||
}
|
||||
>
|
||||
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-full" ref={editorWrapperRef}>
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={() => {
|
||||
// read-only
|
||||
}}
|
||||
readOnly={true}
|
||||
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
|
||||
extensions={editorExtensions}
|
||||
onViewReady={(view) => { editorViewRef.current = view; }}
|
||||
onViewDestroy={() => { editorViewRef.current = null; }}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
end: Math.max(lineSelection.start, lineSelection.end),
|
||||
}
|
||||
: undefined}
|
||||
lineNumbersConfig={{
|
||||
domEventHandlers: {
|
||||
mousedown: (view, line, event) => {
|
||||
if (!(event instanceof MouseEvent)) return false;
|
||||
if (event.button !== 0) return false;
|
||||
event.preventDefault();
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
|
||||
if (isMobile && lineSelection && !event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
||||
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
isSelectingRef.current = false;
|
||||
selectionStartRef.current = null;
|
||||
// Mobile tap-extend is atomic, so we don't start drag
|
||||
setIsDragging(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
isSelectingRef.current = true;
|
||||
selectionStartRef.current = lineNumber;
|
||||
setIsDragging(true);
|
||||
|
||||
if (lineSelection && event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineNumber);
|
||||
const end = Math.max(lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
} else {
|
||||
setLineSelection({ start: lineNumber, end: lineNumber });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
mouseover: (view, line, event) => {
|
||||
if (!(event instanceof MouseEvent)) return false;
|
||||
if (event.buttons !== 1) return false;
|
||||
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
||||
: undefined}
|
||||
lineNumbersConfig={{
|
||||
domEventHandlers: {
|
||||
mousedown: (view, line, event) => {
|
||||
if (!(event instanceof MouseEvent)) return false;
|
||||
if (event.button !== 0) return false;
|
||||
event.preventDefault();
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||
|
||||
if (isMobile && lineSelection && !event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
||||
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
setIsDragging(true);
|
||||
return false;
|
||||
},
|
||||
mouseup: () => {
|
||||
isSelectingRef.current = false;
|
||||
selectionStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
return false;
|
||||
},
|
||||
return true;
|
||||
}
|
||||
|
||||
isSelectingRef.current = true;
|
||||
selectionStartRef.current = lineNumber;
|
||||
setIsDragging(true);
|
||||
|
||||
if (lineSelection && event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineNumber);
|
||||
const end = Math.max(lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
} else {
|
||||
setLineSelection({ start: lineNumber, end: lineNumber });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{floatingComments}
|
||||
</div>
|
||||
)}
|
||||
mouseover: (view, line, event) => {
|
||||
if (!(event instanceof MouseEvent)) return false;
|
||||
if (event.buttons !== 1) return false;
|
||||
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
setIsDragging(true);
|
||||
return false;
|
||||
},
|
||||
mouseup: () => {
|
||||
isSelectingRef.current = false;
|
||||
selectionStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user