Consolidate multi-line comment inputs in Plan and File views* (#371)

* refactor: adapt BlockWidget to use shared container map

Enable BlockWidget to reuse DOM containers via a shared map
Pass container map into widget decorations to preserve portals across updates

* feat: enable drag-to-select and guard new comments in FilesView

Guard new comment input from opening during drag-to-select actions
Enable drag-based line selection with initial drag state and mobile support
Reset dragging state on mouseup to end the selection

* fix(PlanView): gate new comment input during dragging

Add drag state to disable new comment input during selection drag
Indicate drag mode during mobile tap-extend to avoid unintended actions
Pass drag state to child UI to reflect ongoing drag
This commit is contained in:
Nelson Pires
2026-02-09 16:52:19 +02:00
committed by GitHub
parent f65388cf13
commit 8d8abdb54b
3 changed files with 74 additions and 68 deletions
@@ -39,22 +39,19 @@ const highlightLinesCompartment = new Compartment();
const blockWidgetsCompartment = new Compartment();
const searchCompartment = new Compartment();
// Map to store widget container elements by ID
// This allows us to render portals into them even if they are created by CM
const widgetContainers = new Map<string, HTMLElement>();
// BlockWidget class definition moved inside helper or adapted to take map
class BlockWidget extends WidgetType {
constructor(readonly id: string) {
constructor(readonly id: string, readonly containerMap: Map<string, HTMLElement>) {
super();
}
toDOM() {
let div = widgetContainers.get(this.id);
let div = this.containerMap.get(this.id);
if (!div) {
div = document.createElement('div');
div.className = 'oc-block-widget';
div.dataset.widgetId = this.id;
widgetContainers.set(this.id, div);
this.containerMap.set(this.id, div);
}
return div;
}
@@ -64,17 +61,13 @@ class BlockWidget extends WidgetType {
}
destroy() {
// Optional: cleanup if needed, but we might want to keep the element for React to unmount gracefully?
// Actually, if CM destroys the DOM, React portal might complain if we don't unmount.
// But since we render portals based on the 'blockWidgets' prop, if the widget is removed from prop,
// the portal will be removed by React.
// If CM removes it because it's out of viewport, we still want the container to exist in our map?
// No, if CM removes it, we should probably let it go.
// But for now let's keep it simple.
// We do NOT remove from map here because CM might destroy the widget
// when it scrolls out of view, but we want to reuse the same container (and Portal)
// when it scrolls back in.
}
}
const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
const createBlockWidgetsExtension = (widgets: BlockWidgetDef[] | undefined, containerMap: Map<string, HTMLElement>) => {
if (!widgets || widgets.length === 0) return [];
return StateField.define<DecorationSet>({
@@ -90,7 +83,7 @@ const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
const line = state.doc.line(w.afterLine);
// Add widget decoration
builder.add(line.to, line.to, Decoration.widget({
widget: new BlockWidget(w.id),
widget: new BlockWidget(w.id, containerMap),
block: true,
side: 1,
}));
@@ -98,16 +91,8 @@ const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
return builder.finish();
},
update(deco, tr) {
// Always rebuild decorations when doc changes or widgets config changes
// But here we only see transaction.
// Since we reconfigure the compartment when props change, this update might mostly handle doc changes.
// For simplicity, we can map existing decorations or rebuild.
// Let's rebuild to ensure correct line placement.
// Wait, we can't access 'widgets' prop here easily unless we use a closure or effect.
// The `create` method runs when state is created.
// When we reconfigure the compartment, `create` might run again or we need `provide`.
// Actually, standard pattern is to map decorations.
// If the doc changed, map the decorations.
// If the widgets prop changed, the compartment reconfigure will handle it (create() will run).
return deco.map(tr.changes);
},
provide: f => EditorView.decorations.from(f)
@@ -168,6 +153,9 @@ export function CodeMirrorEditor({
const onViewReadyRef = React.useRef(onViewReady);
const onViewDestroyRef = React.useRef(onViewDestroy);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
// Scoped map for widget containers to avoid global collisions and memory leaks
const widgetContainersRef = React.useRef(new Map<string, HTMLElement>());
React.useEffect(() => {
valueRef.current = value;
@@ -209,7 +197,7 @@ export function CodeMirrorEditor({
editableCompartment.of(EditorView.editable.of(!readOnly)),
externalExtensionsCompartment.of(extensions ?? []),
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets)),
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets, widgetContainersRef.current)),
searchCompartment.of(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
],
});
@@ -243,10 +231,14 @@ export function CodeMirrorEditor({
editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
externalExtensionsCompartment.reconfigure(extensions ?? []),
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets)),
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets, widgetContainersRef.current)),
searchCompartment.reconfigure(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
],
});
// 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]);
React.useEffect(() => {
@@ -288,10 +280,10 @@ export function CodeMirrorEditor({
)}
/>
{blockWidgets?.map((w) => {
// Look for the widget container in the editor DOM
// Since we store them in a map too (as backup/optimization), we could check there,
// but querySelector is safer to ensure it's actually in the DOM
const container = viewRef.current?.dom.querySelector(`[data-widget-id="${w.id}"]`);
// Look for the widget container in our scoped map
// We prefer the map over querySelector because the container might be created but not yet attached,
// or detached temporarily by CM (virtual scrolling). Keeping the portal mounted preserves state.
const container = widgetContainersRef.current.get(w.id);
if (!container) return null;
return createPortal(w.content, container, w.id);
})}
+30 -24
View File
@@ -647,6 +647,7 @@ export const FilesView: React.FC = () => {
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const isSelectingRef = React.useRef(false);
const selectionStartRef = React.useRef<number | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
// Session/config for sending comments
const currentSessionId = useSessionStore((state) => state.currentSessionId);
@@ -663,6 +664,7 @@ export const FilesView: React.FC = () => {
const handleGlobalMouseUp = () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
@@ -1805,10 +1807,10 @@ export const FilesView: React.FC = () => {
});
// Add input for new comment
if (lineSelection && !editingDraftId) {
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'new-comment-input',
id: 'files-new-comment-input',
content: (
<InlineCommentInput
lineRange={lineSelection}
@@ -1820,7 +1822,7 @@ export const FilesView: React.FC = () => {
}
return widgets;
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft]);
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]);
const fileViewer = (
<div
@@ -2205,19 +2207,21 @@ export const FilesView: React.FC = () => {
const lineNumber = view.state.doc.lineAt(line.from).number;
// Mobile: tap-to-extend selection
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;
return true;
}
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;
setIsDragging(false);
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
if (lineSelection && event.shiftKey) {
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
@@ -2239,17 +2243,19 @@ export const FilesView: React.FC = () => {
}
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 });
return false;
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;
},
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
return false;
},
},
}}
/>
</div>
+20 -12
View File
@@ -156,11 +156,13 @@ export const PlanView: React.FC = () => {
}, []);
const isSelectingRef = React.useRef(false);
const selectionStartRef = React.useRef<number | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
React.useEffect(() => {
const handleGlobalMouseUp = () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
@@ -428,10 +430,10 @@ export const PlanView: React.FC = () => {
});
// Add new comment input if selecting AND not editing an existing draft
if (lineSelection && !editingDraftId) {
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'new-comment-input',
id: 'plan-new-comment-input',
content: (
<InlineCommentInput
fileLabel={fileLabel}
@@ -457,6 +459,7 @@ export const PlanView: React.FC = () => {
handleSaveComment,
handleCancelComment,
removeDraft,
isDragging,
]);
return (
@@ -593,11 +596,14 @@ export const PlanView: React.FC = () => {
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);
@@ -614,17 +620,19 @@ export const PlanView: React.FC = () => {
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 });
return false;
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;
},
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
return false;
},
},
}}
/>
</div>