From e7b9a9bb616f20a0630590ad843f7815c93a7eca Mon Sep 17 00:00:00 2001 From: Moustafa Sabry Date: Thu, 20 Aug 2026 15:21:10 +0100 Subject: [PATCH] fix(web): triple-click selects the clicked composer line Lexical's triple-click normalization assumes one paragraph per line, but the composer keeps the whole prompt in a single paragraph with line breaks, so triple-clicking any line above the last selected the entire prompt. Snapshot the browser's native line selection before Lexical's click handler runs and restore it afterwards, without swallowing the event. Co-Authored-By: Claude Fable 5 --- .../src/components/ComposerPromptEditor.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..d8436e80c9a1 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1076,6 +1076,71 @@ function ComposerHomeEndKeyPlugin() { return null; } +function ComposerTripleClickLinePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + let snapshot: { + anchorNode: Node; + anchorOffset: number; + focusNode: Node; + focusOffset: number; + } | null = null; + + const captureClick = (event: MouseEvent) => { + snapshot = null; + if (event.detail !== 3) return; + const rootElement = editor.getRootElement(); + if (!rootElement || !(event.target instanceof Node) || !rootElement.contains(event.target)) { + return; + } + const selection = window.getSelection(); + if (!selection?.anchorNode || !selection.focusNode || selection.isCollapsed) return; + snapshot = { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + }; + }; + + const restoreClick = (event: MouseEvent) => { + const saved = snapshot; + snapshot = null; + if (!saved || event.detail !== 3) return; + if (!saved.anchorNode.isConnected || !saved.focusNode.isConnected) return; + const selection = window.getSelection(); + if (!selection) return; + if ( + selection.anchorNode === saved.anchorNode && + selection.anchorOffset === saved.anchorOffset && + selection.focusNode === saved.focusNode && + selection.focusOffset === saved.focusOffset + ) { + return; + } + selection.setBaseAndExtent( + saved.anchorNode, + saved.anchorOffset, + saved.focusNode, + saved.focusOffset, + ); + editor.update(() => { + $setSelection($createRangeSelectionFromDom(selection, editor)); + }); + }; + + document.addEventListener("click", captureClick, true); + document.addEventListener("click", restoreClick); + return () => { + document.removeEventListener("click", captureClick, true); + document.removeEventListener("click", restoreClick); + }; + }, [editor]); + + return null; +} + function ComposerInlineTokenSelectionNormalizePlugin() { const [editor] = useLexicalComposerContext(); @@ -1776,6 +1841,7 @@ function ComposerPromptEditorInner({ +