fix(web): skip filler br element in contenteditable composer

This commit is contained in:
weishu
2026-08-05 00:01:38 +08:00
parent 807fa72aaa
commit b31c5a8e76
2 changed files with 53 additions and 0 deletions
@@ -40,6 +40,28 @@ describe('segmentsFromEditor', () => {
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\nb')
})
it('ignores the filler br browsers park in an emptied editor', () => {
// Deleting the last character leaves Chromium/WebKit with `<br>` as the
// sole child. Counting it made an empty composer hold "\n".
const root = document.createElement('div')
root.innerHTML = '<br>'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('')
expect(mirrorOffsetFromPoint(root, root, 0)).toBe(0)
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(0)
const moz = document.createElement('div')
moz.innerHTML = '<br type="_moz">'
expect(serializeComposerSegments(segmentsFromEditor(moz))).toBe('')
})
it('keeps a trailing br that carries our caret pad (real trailing newline)', () => {
// renderSegmentsToEditor maps "a\n" → text + <br> + ZWSP; the pad is
// what separates a deliberate break from browser filler.
const root = document.createElement('div')
root.innerHTML = 'a<br>'
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\n')
})
it('preserves blank-line endings from renderSegments (br+br+pad)', () => {
// renderSegmentsToEditor maps "...\n\n" → text + <br> + <br> + ZWSP.
// Must not strip a real trailing blank line on re-serialize.
@@ -142,6 +142,29 @@ function selectionIsAfterCaretPad(
&& caretIsAfterCaretPad(root)
}
/**
* True for the bogus `<br>` Chromium/WebKit/Gecko park in an editor whose
* content was just deleted (Firefox marks it `type="_moz"`, Blink leaves it
* bare). It is a caret placeholder, not user content: counting it as a newline
* leaves a visually empty composer holding `"\n"` forever, which then persists
* as a draft and repaints as a real blank first line.
*
* Our own renderer never emits a naked trailing `<br>` — a real trailing
* newline always carries a CARET_PAD text node after it (see
* `renderSegmentsToEditor` / `insertLineBreakAtCaret`), so "nothing at all
* after this node" is exactly the filler shape.
*/
function isFillerLineBreak(root: HTMLElement, br: Node): boolean {
for (let node: Node | null = br; node && node !== root; node = node.parentNode) {
for (let next = node.nextSibling; next; next = next.nextSibling) {
if (next.nodeType !== Node.TEXT_NODE) return false
// Raw length, not stripCaretPad: the pad marks a deliberate break.
if ((next.textContent ?? '').length > 0) return false
}
}
return true
}
type ComposerDomSpan = {
/** Mirror offset at the point inside this node where its visible content begins. */
start: number
@@ -225,6 +248,11 @@ function mapComposerEditorDom(root: HTMLElement): ComposerDomMapping {
return
}
if (el.tagName === 'BR') {
if (isFillerLineBreak(root, node)) {
// Zero-width: the caret placeholder owns no mirror position.
spans.set(node, { start: mirrorLength, end: mirrorLength, producesOutput: false })
return
}
pushNewlineIfNeeded()
const start = mirrorLength
pushText('\n')
@@ -565,6 +593,9 @@ function setMirrorSelection(root: HTMLElement, selection: ComposerSelection) {
if (el.tagName === 'BR') {
const parent = el.parentNode
if (!parent) return true
// Mirror mapping gives a filler br zero width; consuming one here
// would shift every later offset by one.
if (isFillerLineBreak(root, el)) return false
if (remaining === 0) {
place(parent, Array.from(parent.childNodes).indexOf(el))
return true