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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user