diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index b4e8f38164..be66f2435a 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -4,6 +4,7 @@ import { rememberDesktopCommandsCatalog } from '@/lib/desktop-slash-commands' import { insertInlineRefsIntoEditor } from './inline-refs' import { + caretOffsetInEditor, composerPlainText, deleteSelectionInEditor, insertComposerContentsAtCaret, @@ -441,3 +442,75 @@ describe('caret placement on a detached editor', () => { attached.remove() }) }) + +describe('normalizeComposerEditorDom — caret preservation', () => { + it('re-establishes a caret anchored inside a removed phantom tail block', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.contentEditable = 'true' + editor.tabIndex = 0 + document.body.append(editor) + editor.focus() + + expect(document.activeElement).toBe(editor) + + const text = document.createTextNode('hi') + const tailBlock = document.createElement('div') + + tailBlock.append(document.createElement('br')) + editor.append(text, tailBlock) + + const caret = document.createRange() + caret.setStart(tailBlock, 0) + caret.collapse(true) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(caret) + + normalizeComposerEditorDom(editor) + + expect(tailBlock.isConnected).toBe(false) + expect(selection.isCollapsed).toBe(true) + + const range = selection.getRangeAt(0) + expect(editor.contains(range.startContainer)).toBe(true) + expect(range.startContainer).toBe(text) + expect(range.startOffset).toBe(2) + + editor.remove() + }) + + it('leaves a still-valid selection untouched', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.contentEditable = 'true' + editor.tabIndex = 0 + document.body.append(editor) + editor.focus() + + const br = document.createElement('br') + editor.append(refChipElement('file', '`a.ts`'), br) + + const caret = document.createRange() + caret.setStart(editor, 1) + caret.collapse(true) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(caret) + + const offsetBefore = caretOffsetInEditor(editor) + + normalizeComposerEditorDom(editor) + + // The trailing
after a chip is gone — normalization did mutate — but + // the selection was valid, so it must come through untouched. + expect(editor.contains(br)).toBe(false) + + const range = selection.getRangeAt(0) + expect(range.startContainer).toBe(editor) + expect(range.startOffset).toBe(1) + expect(caretOffsetInEditor(editor)).toBe(offsetBefore) + + editor.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index bd6036db5b..31f2251b24 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -708,6 +708,42 @@ export function placeCaretAtOffset(editor: HTMLElement, offset: number) { placeCaretEnd(editor) } +/** The live collapsed caret inside `editor`, or `null` when the editor does + * not currently hold it (no selection, multi-range, or the anchor lives in + * another element — a hidden keep-alive composer must not repatriate a + * selection the visible one owns). `node`/`offset` is the boundary itself; + * `textOffset` is the same position in `composerPlainText` coordinates, the + * units `placeCaretAtOffset` restores with. */ +interface ComposerCaretSnapshot { + node: Node + offset: number + textOffset: number +} + +function composerCaretSnapshotIfInside(editor: HTMLElement): ComposerCaretSnapshot | null { + const selection = window.getSelection() + + if (!selection || !selection.isCollapsed || selection.rangeCount !== 1) { + return null + } + + const range = selection.getRangeAt(0) + + if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) { + return null + } + + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + + const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT + container.append(before.cloneContents()) + + return { node: range.startContainer, offset: range.startOffset, textOffset: composerPlainText(container).length } +} + /** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */ function isBlankNode(node: ChildNode | null): boolean { if (!node) { @@ -737,6 +773,16 @@ function isBlankNode(node: ChildNode | null): boolean { * rendering emits (we use text nodes +
+ chips). Real
line breaks * (Shift+Enter, which sit after actual text) are preserved. */ export function normalizeComposerEditorDom(editor: HTMLElement) { + // Selection is document-global and every path below can delete the node the + // caret is anchored in (the #88621 class: the editor stays `activeElement`, + // the range points at a detached node, and printable keys stop producing + // input events until a click restores the selection). Normalization is + // text-preserving, so the caret is snapshotted as a `composerPlainText` + // offset — the same coordinates the undo/redo caret save already uses — and + // re-established afterwards only when some path actually detached it. + // Skipped during IME composition: the preedit caret is Chromium's to move. + const caret = document.activeElement === editor ? composerCaretSnapshotIfInside(editor) : null + const selectedContainer = composerCollapsedSelectionContainer(editor) // Chromium's zero-length text nodes first: every check below reads siblings, @@ -794,4 +840,30 @@ export function normalizeComposerEditorDom(editor: HTMLElement) { if (editor.childNodes.length === 0) { editor.appendChild(document.createElement('br')) } + + if (!caret) { + return + } + + const selection = window.getSelection() + + // Nothing to do while the caret still sits on its original anchor — the + // common case: none of the paths below touched the caret's own node. + // Removing that node does NOT reliably lose the caret: the DOM re-anchors + // the boundary in the removed node's parent, which reads as "valid" while + // the caret has drifted to a container-level position. The anchor node's + // presence is what tells the two apart — gone means restore. + if ( + selection?.rangeCount === 1 && + editor.contains(selection.getRangeAt(0).startContainer) && + selection.getRangeAt(0).startContainer === caret.node + ) { + return + } + + // The caret's anchor is gone — re-establish it at the equivalent plain-text + // position (never inside a chip: placeCaretAtOffset treats chips as atomic + // and stops before them, which is where a caret would sit after the next + // backspace anyway). + placeCaretAtOffset(editor, Math.min(caret.textOffset, composerPlainText(editor).length)) }