fix(desktop): restore the caret when normalization removes its anchor

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)
This commit is contained in:
Hermes
2026-09-13 19:20:50 +03:00
committed by brooklyn!
parent 79e8aab91c
commit b4d75f123b
2 changed files with 145 additions and 0 deletions

View File

@@ -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 <br> 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()
})
})

View File

@@ -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 + <br> + chips). Real <br> 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))
}