From b4d75f123be8cd1624db7c0cf20caa502c95250f Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 13 Sep 2026 19:20:50 +0300 Subject: [PATCH] fix(desktop): restore the caret when normalization removes its anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #100302 sweep guard keeps the caret's own empty text node alive, but normalizeComposerEditorDom has three more paths that can delete the node a live caret is anchored in: the phantom tail-block removal, the trailing-br removal, and the block unwrap. Per the DOM spec a boundary re-anchors in the removed node's parent, so the caret reads as "valid" while its position has drifted (the #88621 class: editor stays activeElement, printable keys stop producing input events, clicking restores typing). Normalization is text-preserving, so this snapshots the caret once — both as the boundary (node, offset) and as a composerPlainText offset, the same units the undo/redo caret save already uses — and re-establishes it via placeCaretAtOffset only when the snapshotted anchor node no longer exists in the editor. Hidden keep-alive composers are excluded: the snapshot is null unless the selection is a single collapsed range inside this editor, so a background repaint never steals the document selection the visible composer owns. Skipped when the editor does not hold focus (document.activeElement). The sweep guard from the previous commit stays: skipping the node is cheaper than a remove-then-restore round-trip and keeps the caret node identity stable through the empty-litter case. Tests: the new invariant (caret restored when its anchor block is removed) fails on the base commit without this change; a second test pins that a still-valid selection passes through untouched. Composer suite 461/461, tsc/eslint/prettier clean. (cherry picked from commit 741265a88499c13f6a3086133067ece9ad8aa619) --- .../src/app/chat/composer/rich-editor.test.ts | 73 +++++++++++++++++++ .../src/app/chat/composer/rich-editor.ts | 72 ++++++++++++++++++ 2 files changed, 145 insertions(+) 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)) }