From 95f0d36adc62eaecf9effe8b62ab8f6f421de904 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 16:07:37 -0600 Subject: [PATCH] Fix @-mention tab completion cursor positioning After tab-completing a mention, the cursor was placed before the completion instead of after it. The root cause: Lexical's DOM reconciliation after document.execCommand("insertText") would lose the browser-set cursor position. Added requestAnimationFrame-based cursor repositioning that first tries the original text node, then falls back to searching for the mention text in the DOM. Also normalizes editor content padding for consistent mention dropdown positioning. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/MarkdownEditor.tsx | 77 ++++++++++++++++++++++++---- ui/src/index.css | 11 ++-- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 4a6202b3..4389dbca 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -231,18 +231,77 @@ export const MarkdownEditor = forwardRef // update state between the last render and this callback firing). const state = mentionStateRef.current; if (!state) return; - const current = latestValueRef.current; - const next = applyMention(current, state.query, option); - if (next !== current) { - latestValueRef.current = next; - ref.current?.setMarkdown(next); - onChange(next); + + const replacement = `@${option.name} `; + + // Replace @query directly via DOM selection so the cursor naturally + // lands after the inserted text. Lexical picks up the change through + // its normal input-event handling. + const sel = window.getSelection(); + if (sel && state.textNode.isConnected) { + const range = document.createRange(); + range.setStart(state.textNode, state.atPos); + range.setEnd(state.textNode, state.endPos); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, replacement); + + // After Lexical reconciles the DOM, the cursor position set by + // execCommand may be lost. Explicitly reposition it after the + // inserted mention text. + const cursorTarget = state.atPos + replacement.length; + requestAnimationFrame(() => { + const newSel = window.getSelection(); + if (!newSel) return; + // Try the original text node first (it may still be valid) + if (state.textNode.isConnected) { + const len = state.textNode.textContent?.length ?? 0; + if (cursorTarget <= len) { + const r = document.createRange(); + r.setStart(state.textNode, cursorTarget); + r.collapse(true); + newSel.removeAllRanges(); + newSel.addRange(r); + return; + } + } + // Fallback: search for the replacement in text nodes + const editable = containerRef.current?.querySelector('[contenteditable="true"]'); + if (!editable) return; + const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + const text = node.textContent ?? ""; + const idx = text.indexOf(replacement); + if (idx !== -1) { + const pos = idx + replacement.length; + if (pos <= text.length) { + const r = document.createRange(); + r.setStart(node, pos); + r.collapse(true); + newSel.removeAllRanges(); + newSel.addRange(r); + return; + } + } + } + }); + } else { + // Fallback: full markdown replacement when DOM node is stale + const current = latestValueRef.current; + const next = applyMention(current, state.query, option); + if (next !== current) { + latestValueRef.current = next; + ref.current?.setMarkdown(next); + onChange(next); + } + requestAnimationFrame(() => { + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); + }); } + mentionStateRef.current = null; setMentionState(null); - requestAnimationFrame(() => { - ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); - }); }, [onChange], ); diff --git a/ui/src/index.css b/ui/src/index.css index 0759c501..f88d4bb9 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -237,12 +237,12 @@ .paperclip-mdxeditor .mdxeditor-root-contenteditable { min-height: 2.5rem; - padding: 0.375rem 0.625rem; + padding: 0; line-height: 1.5; } -.paperclip-mdxeditor--borderless .mdxeditor-root-contenteditable { - padding: 0; +.paperclip-mdxeditor [class*="_contentEditable_"] { + padding: 0.375rem 0.625rem !important; } .paperclip-mdxeditor--borderless [class*="_contentEditable_"] { @@ -250,17 +250,12 @@ } .paperclip-mdxeditor [class*="_placeholder_"] { - padding: 0.375rem 0.625rem; font-family: inherit; font-size: 0.875rem; line-height: 1.5; color: var(--muted-foreground); } -.paperclip-mdxeditor--borderless [class*="_placeholder_"] { - padding: 0; -} - .paperclip-mdxeditor-content { font-family: inherit; font-size: inherit;