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;