mirror of
https://github.com/penpot/penpot.git
synced 2026-08-10 14:59:08 +00:00
🐛 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:
parent
e01b36b841
commit
b0014d92aa
67
frontend/text-editor/src/editor/clipboard/paste.test.js
Normal file
67
frontend/text-editor/src/editor/clipboard/paste.test.js
Normal 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!");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, "], {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user