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:
@@ -39,22 +39,19 @@ const highlightLinesCompartment = new Compartment();
|
|||||||
const blockWidgetsCompartment = new Compartment();
|
const blockWidgetsCompartment = new Compartment();
|
||||||
const searchCompartment = new Compartment();
|
const searchCompartment = new Compartment();
|
||||||
|
|
||||||
// Map to store widget container elements by ID
|
// BlockWidget class definition moved inside helper or adapted to take map
|
||||||
// This allows us to render portals into them even if they are created by CM
|
|
||||||
const widgetContainers = new Map<string, HTMLElement>();
|
|
||||||
|
|
||||||
class BlockWidget extends WidgetType {
|
class BlockWidget extends WidgetType {
|
||||||
constructor(readonly id: string) {
|
constructor(readonly id: string, readonly containerMap: Map<string, HTMLElement>) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
let div = widgetContainers.get(this.id);
|
let div = this.containerMap.get(this.id);
|
||||||
if (!div) {
|
if (!div) {
|
||||||
div = document.createElement('div');
|
div = document.createElement('div');
|
||||||
div.className = 'oc-block-widget';
|
div.className = 'oc-block-widget';
|
||||||
div.dataset.widgetId = this.id;
|
div.dataset.widgetId = this.id;
|
||||||
widgetContainers.set(this.id, div);
|
this.containerMap.set(this.id, div);
|
||||||
}
|
}
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
@@ -64,17 +61,13 @@ class BlockWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
// Optional: cleanup if needed, but we might want to keep the element for React to unmount gracefully?
|
// We do NOT remove from map here because CM might destroy the widget
|
||||||
// Actually, if CM destroys the DOM, React portal might complain if we don't unmount.
|
// when it scrolls out of view, but we want to reuse the same container (and Portal)
|
||||||
// But since we render portals based on the 'blockWidgets' prop, if the widget is removed from prop,
|
// when it scrolls back in.
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
|
const createBlockWidgetsExtension = (widgets: BlockWidgetDef[] | undefined, containerMap: Map<string, HTMLElement>) => {
|
||||||
if (!widgets || widgets.length === 0) return [];
|
if (!widgets || widgets.length === 0) return [];
|
||||||
|
|
||||||
return StateField.define<DecorationSet>({
|
return StateField.define<DecorationSet>({
|
||||||
@@ -90,7 +83,7 @@ const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
|
|||||||
const line = state.doc.line(w.afterLine);
|
const line = state.doc.line(w.afterLine);
|
||||||
// Add widget decoration
|
// Add widget decoration
|
||||||
builder.add(line.to, line.to, Decoration.widget({
|
builder.add(line.to, line.to, Decoration.widget({
|
||||||
widget: new BlockWidget(w.id),
|
widget: new BlockWidget(w.id, containerMap),
|
||||||
block: true,
|
block: true,
|
||||||
side: 1,
|
side: 1,
|
||||||
}));
|
}));
|
||||||
@@ -98,16 +91,8 @@ const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
|
|||||||
return builder.finish();
|
return builder.finish();
|
||||||
},
|
},
|
||||||
update(deco, tr) {
|
update(deco, tr) {
|
||||||
// Always rebuild decorations when doc changes or widgets config changes
|
// If the doc changed, map the decorations.
|
||||||
// But here we only see transaction.
|
// If the widgets prop changed, the compartment reconfigure will handle it (create() will run).
|
||||||
// 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.
|
|
||||||
return deco.map(tr.changes);
|
return deco.map(tr.changes);
|
||||||
},
|
},
|
||||||
provide: f => EditorView.decorations.from(f)
|
provide: f => EditorView.decorations.from(f)
|
||||||
@@ -169,6 +154,9 @@ export function CodeMirrorEditor({
|
|||||||
const onViewDestroyRef = React.useRef(onViewDestroy);
|
const onViewDestroyRef = React.useRef(onViewDestroy);
|
||||||
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
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(() => {
|
React.useEffect(() => {
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@@ -209,7 +197,7 @@ export function CodeMirrorEditor({
|
|||||||
editableCompartment.of(EditorView.editable.of(!readOnly)),
|
editableCompartment.of(EditorView.editable.of(!readOnly)),
|
||||||
externalExtensionsCompartment.of(extensions ?? []),
|
externalExtensionsCompartment.of(extensions ?? []),
|
||||||
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
|
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
|
||||||
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets)),
|
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets, widgetContainersRef.current)),
|
||||||
searchCompartment.of(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
|
searchCompartment.of(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -243,10 +231,14 @@ export function CodeMirrorEditor({
|
|||||||
editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
|
editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
|
||||||
externalExtensionsCompartment.reconfigure(extensions ?? []),
|
externalExtensionsCompartment.reconfigure(extensions ?? []),
|
||||||
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
|
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
|
||||||
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets)),
|
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets, widgetContainersRef.current)),
|
||||||
searchCompartment.reconfigure(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
|
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]);
|
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -288,10 +280,10 @@ export function CodeMirrorEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{blockWidgets?.map((w) => {
|
{blockWidgets?.map((w) => {
|
||||||
// Look for the widget container in the editor DOM
|
// Look for the widget container in our scoped map
|
||||||
// Since we store them in a map too (as backup/optimization), we could check there,
|
// We prefer the map over querySelector because the container might be created but not yet attached,
|
||||||
// but querySelector is safer to ensure it's actually in the DOM
|
// or detached temporarily by CM (virtual scrolling). Keeping the portal mounted preserves state.
|
||||||
const container = viewRef.current?.dom.querySelector(`[data-widget-id="${w.id}"]`);
|
const container = widgetContainersRef.current.get(w.id);
|
||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
return createPortal(w.content, container, w.id);
|
return createPortal(w.content, container, w.id);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ export const FilesView: React.FC = () => {
|
|||||||
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
||||||
const isSelectingRef = React.useRef(false);
|
const isSelectingRef = React.useRef(false);
|
||||||
const selectionStartRef = React.useRef<number | null>(null);
|
const selectionStartRef = React.useRef<number | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
// Session/config for sending comments
|
// Session/config for sending comments
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
@@ -663,6 +664,7 @@ export const FilesView: React.FC = () => {
|
|||||||
const handleGlobalMouseUp = () => {
|
const handleGlobalMouseUp = () => {
|
||||||
isSelectingRef.current = false;
|
isSelectingRef.current = false;
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||||
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||||
@@ -1805,10 +1807,10 @@ export const FilesView: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add input for new comment
|
// Add input for new comment
|
||||||
if (lineSelection && !editingDraftId) {
|
if (lineSelection && !editingDraftId && !isDragging) {
|
||||||
widgets.push({
|
widgets.push({
|
||||||
afterLine: lineSelection.end,
|
afterLine: lineSelection.end,
|
||||||
id: 'new-comment-input',
|
id: 'files-new-comment-input',
|
||||||
content: (
|
content: (
|
||||||
<InlineCommentInput
|
<InlineCommentInput
|
||||||
lineRange={lineSelection}
|
lineRange={lineSelection}
|
||||||
@@ -1820,7 +1822,7 @@ export const FilesView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return widgets;
|
return widgets;
|
||||||
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft]);
|
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]);
|
||||||
|
|
||||||
const fileViewer = (
|
const fileViewer = (
|
||||||
<div
|
<div
|
||||||
@@ -2205,19 +2207,21 @@ export const FilesView: React.FC = () => {
|
|||||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||||
|
|
||||||
// Mobile: tap-to-extend selection
|
// Mobile: tap-to-extend selection
|
||||||
if (isMobile && lineSelection && !event.shiftKey) {
|
if (isMobile && lineSelection && !event.shiftKey) {
|
||||||
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
||||||
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
||||||
setLineSelection({ start, end });
|
setLineSelection({ start, end });
|
||||||
isSelectingRef.current = false;
|
isSelectingRef.current = false;
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
return true;
|
setIsDragging(false);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
isSelectingRef.current = true;
|
isSelectingRef.current = true;
|
||||||
selectionStartRef.current = lineNumber;
|
selectionStartRef.current = lineNumber;
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
if (lineSelection && event.shiftKey) {
|
if (lineSelection && event.shiftKey) {
|
||||||
const start = Math.min(lineSelection.start, lineNumber);
|
const start = Math.min(lineSelection.start, lineNumber);
|
||||||
const end = Math.max(lineSelection.end, lineNumber);
|
const end = Math.max(lineSelection.end, lineNumber);
|
||||||
setLineSelection({ start, end });
|
setLineSelection({ start, end });
|
||||||
@@ -2239,17 +2243,19 @@ export const FilesView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||||
setLineSelection({ start, end });
|
setLineSelection({ start, end });
|
||||||
return false;
|
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>
|
</div>
|
||||||
|
|||||||
@@ -156,11 +156,13 @@ export const PlanView: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
const isSelectingRef = React.useRef(false);
|
const isSelectingRef = React.useRef(false);
|
||||||
const selectionStartRef = React.useRef<number | null>(null);
|
const selectionStartRef = React.useRef<number | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleGlobalMouseUp = () => {
|
const handleGlobalMouseUp = () => {
|
||||||
isSelectingRef.current = false;
|
isSelectingRef.current = false;
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||||
return () => document.removeEventListener('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
|
// Add new comment input if selecting AND not editing an existing draft
|
||||||
if (lineSelection && !editingDraftId) {
|
if (lineSelection && !editingDraftId && !isDragging) {
|
||||||
widgets.push({
|
widgets.push({
|
||||||
afterLine: lineSelection.end,
|
afterLine: lineSelection.end,
|
||||||
id: 'new-comment-input',
|
id: 'plan-new-comment-input',
|
||||||
content: (
|
content: (
|
||||||
<InlineCommentInput
|
<InlineCommentInput
|
||||||
fileLabel={fileLabel}
|
fileLabel={fileLabel}
|
||||||
@@ -457,6 +459,7 @@ export const PlanView: React.FC = () => {
|
|||||||
handleSaveComment,
|
handleSaveComment,
|
||||||
handleCancelComment,
|
handleCancelComment,
|
||||||
removeDraft,
|
removeDraft,
|
||||||
|
isDragging,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -593,11 +596,14 @@ export const PlanView: React.FC = () => {
|
|||||||
setLineSelection({ start, end });
|
setLineSelection({ start, end });
|
||||||
isSelectingRef.current = false;
|
isSelectingRef.current = false;
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
|
// Mobile tap-extend is atomic, so we don't start drag
|
||||||
|
setIsDragging(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelectingRef.current = true;
|
isSelectingRef.current = true;
|
||||||
selectionStartRef.current = lineNumber;
|
selectionStartRef.current = lineNumber;
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
if (lineSelection && event.shiftKey) {
|
if (lineSelection && event.shiftKey) {
|
||||||
const start = Math.min(lineSelection.start, lineNumber);
|
const start = Math.min(lineSelection.start, lineNumber);
|
||||||
@@ -614,17 +620,19 @@ export const PlanView: React.FC = () => {
|
|||||||
if (event.buttons !== 1) return false;
|
if (event.buttons !== 1) return false;
|
||||||
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
||||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||||
setLineSelection({ start, end });
|
setLineSelection({ start, end });
|
||||||
return false;
|
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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user