🐛 Fix crash when pasting into an empty or element-focused caret

Pasting text could throw "Unknown node type" and lose the paste. The
insertion paths assume the caret sits on a text node or a <br>, but the
browser can report it on a container element (the offset being a child
index, common in Firefox) or, for an empty text shape that was just
focused, on nothing at all: selectAll() returned early without ever
setting a selection.

Add resolveTextNodePosition(), which walks a (node, offset) pair down to
the addressed text node or line break and returns null instead of
throwing when it cannot. The selection controller normalizes the caret
with it before inserting text or a pasted fragment, and selectAll() now
collapses on the line break of an empty editor so the caret is always
usable.

Closes #11149

AI-assisted-by: longcat-2.0-free
This commit is contained in:
Andrey Antukh 2026-08-08 19:27:22 +00:00
parent e01b36b841
commit b0014d92aa
6 changed files with 375 additions and 2 deletions

View File

@ -0,0 +1,67 @@
import { describe, test, expect } from "vitest";
import { TextEditorMock } from "../../test/TextEditorMock.js";
import { SelectionController } from "../controllers/SelectionController.js";
import { paste } from "./paste.js";
/* @vitest-environment jsdom */
/**
* Creates a minimal `ClipboardEvent`-like object carrying plain text.
*
* @param {string} text
* @returns {object}
*/
function createPlainTextClipboardEvent(text) {
return {
preventDefault() {},
clipboardData: {
types: ["text/plain"],
getData(type) {
return type === "text/plain" ? text : "";
},
},
};
}
describe("paste", () => {
test("should insert plain text into an empty editor that was just focused", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
paste(
createPlainTextClipboardEvent("Hello, World!"),
textEditorMock,
selectionController,
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
test("should insert plain text when the caret is on a paragraph element", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("Hello, ");
const root = textEditorMock.root;
const paragraph = root.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selection.setBaseAndExtent(paragraph, 1, paragraph, 1);
document.dispatchEvent(new Event("selectionchange"));
paste(
createPlainTextClipboardEvent("World!"),
textEditorMock,
selectionController,
);
expect(root.textContent).toBe("Hello, World!");
});
});

View File

@ -62,3 +62,56 @@ export function getClosestTextNode(node) {
if (isEditor(node)) return node.firstChild.firstChild.firstChild.firstChild;
throw new Error("Cannot find a text node");
}
/**
* @typedef {Object} TextNodePosition
* @property {Text|HTMLBRElement} node
* @property {number} offset
*/
/**
* Resolves a (node, offset) pair to an equivalent position on a text node
* or a line break.
*
* Browsers are free to report a caret on a container element, in which case
* the offset is a child index instead of a character index (Firefox does this
* routinely, e.g. on empty paragraphs). This function walks down the content
* tree to the addressed descendant so callers can always work with text
* nodes.
*
* Unlike `getClosestTextNode`, this never throws: it returns `null` when the
* position cannot be resolved, letting the caller decide the fallback.
*
* @param {Node} node
* @param {number} [offset=0]
* @returns {TextNodePosition|null}
*/
export function resolveTextNodePosition(node, offset = 0) {
if (!node) return null;
if (node.nodeType === Node.TEXT_NODE || isLineBreak(node)) {
return { node, offset };
}
if (isTextSpan(node)) {
// Within a text span the children are text nodes or a line break, so an
// index past the last child means "at the end of the last child".
const child = node.childNodes[offset];
if (child) return resolveTextNodePosition(child, 0);
const lastChild = node.lastChild;
if (!lastChild) return null;
if (lastChild.nodeType !== Node.TEXT_NODE && !isLineBreak(lastChild)) {
return null;
}
return resolveTextNodePosition(lastChild, getTextNodeLength(lastChild));
}
if (isParagraph(node) || isRoot(node) || isEditor(node)) {
const child = node.children[offset];
if (child) return resolveTextNodePosition(child, 0);
const lastChild = node.lastElementChild;
if (!lastChild) return null;
return resolveTextNodePosition(lastChild, lastChild.childNodes.length);
}
return null;
}

View File

@ -1,6 +1,13 @@
import { describe, test, expect } from "vitest";
import { isTextNode, getTextNodeLength } from "./TextNode.js";
import {
isTextNode,
getTextNodeLength,
resolveTextNodePosition,
} from "./TextNode.js";
import { createLineBreak } from "./LineBreak.js";
import { createTextSpan, createEmptyTextSpan } from "./TextSpan.js";
import { createParagraph } from "./Paragraph.js";
import { createRoot } from "./Root.js";
/* @vitest-environment jsdom */
describe("TextNode", () => {
@ -25,4 +32,104 @@ describe("TextNode", () => {
expect(() => getTextNodeLength(null)).toThrowError("Invalid text node");
expect(() => getTextNodeLength(0)).toThrowError("Invalid text node");
});
describe("resolveTextNodePosition", () => {
test("should return the same position when the node is already a text node", () => {
const textNode = new Text("Hello, World!");
expect(resolveTextNodePosition(textNode, 5)).toStrictEqual({
node: textNode,
offset: 5,
});
});
test("should return the same position when the node is a line break", () => {
const lineBreak = createLineBreak();
expect(resolveTextNodePosition(lineBreak, 0)).toStrictEqual({
node: lineBreak,
offset: 0,
});
});
test("should resolve a text span to its child at the given index", () => {
const textNode = new Text("Hello");
const textSpan = createTextSpan(textNode);
expect(resolveTextNodePosition(textSpan, 0)).toStrictEqual({
node: textNode,
offset: 0,
});
});
test("should resolve a text span index past the last child to the end of its text", () => {
const textNode = new Text("Hello");
const textSpan = createTextSpan(textNode);
expect(resolveTextNodePosition(textSpan, 1)).toStrictEqual({
node: textNode,
offset: 5,
});
});
test("should resolve a paragraph to the text node of the indexed text span", () => {
const first = new Text("Hello, ");
const second = new Text("World!");
const paragraph = createParagraph([
createTextSpan(first),
createTextSpan(second),
]);
expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({
node: first,
offset: 0,
});
expect(resolveTextNodePosition(paragraph, 1)).toStrictEqual({
node: second,
offset: 0,
});
expect(resolveTextNodePosition(paragraph, 2)).toStrictEqual({
node: second,
offset: 6,
});
});
test("should resolve an empty paragraph to its line break", () => {
const textSpan = createEmptyTextSpan();
const paragraph = createParagraph([textSpan]);
expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({
node: textSpan.firstChild,
offset: 0,
});
});
test("should resolve a root to the text node of the indexed paragraph", () => {
const first = new Text("Hello, ");
const second = new Text("World!");
const root = createRoot([
createParagraph([createTextSpan(first)]),
createParagraph([createTextSpan(second)]),
]);
expect(resolveTextNodePosition(root, 1)).toStrictEqual({
node: second,
offset: 0,
});
});
test("should resolve an editor element to the first text node of its root", () => {
const textNode = new Text("Hello");
const root = createRoot([createParagraph([createTextSpan(textNode)])]);
const editor = document.createElement("div");
editor.dataset.itype = "editor";
editor.appendChild(root);
expect(resolveTextNodePosition(editor, 0)).toStrictEqual({
node: textNode,
offset: 0,
});
});
test("should return null instead of throwing when the position cannot be resolved", () => {
expect(resolveTextNodePosition(null, 0)).toBe(null);
expect(resolveTextNodePosition(undefined, 0)).toBe(null);
expect(resolveTextNodePosition(document.createElement("div"), 0)).toBe(
null,
);
expect(resolveTextNodePosition(createParagraph([]), 0)).toBe(null);
});
});
});

View File

@ -46,6 +46,7 @@ import {
getTextNodeLength,
getClosestTextNode,
isTextNode,
resolveTextNodePosition,
} from "../content/dom/TextNode.js";
import TextNodeIterator from "../content/dom/TextNodeIterator.js";
import TextEditor from "../TextEditor.js";
@ -537,6 +538,14 @@ export class SelectionController extends EventTarget {
*/
selectAll() {
if (this.#textEditor.isEmpty) {
// There is nothing to select, but we still need a valid caret: leaving
// the selection untouched keeps `focusNode` null and makes any later
// insertion (typing, pasting) fail.
const lineBreak =
this.#textEditor.root?.firstElementChild?.firstElementChild?.firstChild;
if (lineBreak) {
this.collapse(lineBreak, 0);
}
return this;
}
@ -1132,6 +1141,10 @@ export class SelectionController extends EventTarget {
* @param {DocumentFragment} fragment
*/
insertPaste(fragment) {
if (this.isCollapsed && !this.#normalizeFocus()) {
return;
}
const hasOnlyOneParagraph = fragment.children.length === 1;
const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force";
@ -1395,6 +1408,33 @@ export class SelectionController extends EventTarget {
return this.collapse(this.focusNode, this.focusOffset + newText.length);
}
/**
* Moves the caret to an equivalent position on a text node or a line break.
*
* The browser can report the caret on a container element (with the offset
* being a child index) or, when the editor was focused without any content,
* on nothing at all. Both states break every insertion path, which expects
* the focus node to be a text node or a <br>.
*
* @returns {boolean} true when the focus is usable.
*/
#normalizeFocus() {
if (this.isTextFocus || this.isLineBreakFocus) {
return true;
}
const position =
resolveTextNodePosition(this.focusNode, this.focusOffset) ??
resolveTextNodePosition(this.#textEditor.root, 0);
if (!position?.node?.isConnected) {
return false;
}
this.collapse(position.node, position.offset);
return true;
}
/**
* Replaces the currently focus element
* with some text.
@ -1402,6 +1442,10 @@ export class SelectionController extends EventTarget {
* @param {string} newText
*/
insertIntoFocus(newText) {
if (!this.#normalizeFocus()) {
return;
}
if (this.isTextFocus) {
this.focusNode.nodeValue = insertInto(
this.focusNode.nodeValue,

View File

@ -1706,6 +1706,96 @@ describe("SelectionController", () => {
);
});
test("`selectAll` should collapse the caret on the line break when the editor is empty", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selectionController.selectAll();
expect(selectionController.focusNode).toBe(
root.firstChild.firstChild.firstChild,
);
expect(selectionController.isCollapsed).toBe(true);
});
test("`insertIntoFocus` should insert text when the focus node is a paragraph", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("Hello, ");
const root = textEditorMock.root;
const paragraph = root.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, paragraph, 1);
selectionController.insertIntoFocus("World!");
expect(root.textContent).toBe("Hello, World!");
});
test("`insertIntoFocus` should insert text when the focus node is the root", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("Hello, ");
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, root, 1);
selectionController.insertIntoFocus("World!");
expect(root.textContent).toBe("Hello, World!");
});
test("`insertIntoFocus` should insert text when the focus node is the editor element", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("Hello, ");
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, textEditorMock.element, 0);
selectionController.insertIntoFocus("World!");
expect(root.textContent).toBe("World!Hello, ");
});
test("`insertIntoFocus` should insert text when there is no known focus node", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
expect(selectionController.focusNode).toBe(null);
selectionController.insertIntoFocus("Hello, World!");
expect(root.textContent).toBe("Hello, World!");
});
test("`insertPaste` should insert a fragment when the focus node is a paragraph", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText(", World!");
const root = textEditorMock.root;
const paragraph = root.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, paragraph, 0);
const fragment = document.createDocumentFragment();
fragment.append(createParagraphWith(["Hello"]));
selectionController.insertPaste(fragment);
expect(root.textContent).toBe("Hello, World!");
});
test("`cursorToEnd` should move cursor to the end", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {

View File

@ -4,7 +4,10 @@ import {
createEmptyTextSpan,
createTextSpan,
} from "../editor/content/dom/TextSpan.js";
import { createLineBreak } from "../editor/content/dom/LineBreak.js";
import {
createLineBreak,
isLineBreak,
} from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget {
/**
@ -135,6 +138,7 @@ export class TextEditorMock extends EventTarget {
this.#element = element;
this.#root = options?.root;
this.#selectionImposterElement = options?.selectionImposterElement;
this.#element.dataset.itype = "editor";
this.#element.appendChild(options?.root);
}
@ -145,6 +149,14 @@ export class TextEditorMock extends EventTarget {
get root() {
return this.#root;
}
get isEmpty() {
return (
this.#root.children.length === 1 &&
this.#root.firstElementChild.children.length === 1 &&
isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild)
);
}
}
export default TextEditorMock;