penpot/frontend/text-editor/src/test/TextEditorMock.js
Andrey Antukh c797656d17
🐛 Fix crash when pasting into an empty or element-focused caret (#11150)
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
2026-08-17 13:32:12 +02:00

163 lines
4.0 KiB
JavaScript

import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph, createParagraphWith } from "../editor/content/dom/Paragraph.js";
import {
createEmptyTextSpan,
createTextSpan,
} from "../editor/content/dom/TextSpan.js";
import {
createLineBreak,
isLineBreak,
} from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget {
/**
* Returns the template used for the text editor mock.
*
* @returns {HTMLDivElement}
*/
static getTemplate() {
const container = document.createElement("div");
container.id = "test";
container.innerHTML = `<div class="text-editor-container align-top">
<div
id="text-editor-selection-imposter"
class="text-editor-selection-imposter"></div>
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
</div>`;
document.body.appendChild(container);
return container;
}
/**
* Creates an editor with a custom root.
*
* @param {HTMLDivElement} root
* @returns {HTMLDivElement}
*/
static createTextEditorMockWithRoot(root) {
const container = TextEditorMock.getTemplate();
const selectionImposterElement = container.querySelector(
".text-editor-selection-imposter",
);
const textEditorMock = new TextEditorMock(
container.querySelector(".text-editor-content"),
{
root,
selectionImposterElement,
},
);
return textEditorMock;
}
/**
* Creates a TextEditor mock with paragraphs.
*
* @param {Array<HTMLDivElement>} paragraphs
* @returns
*/
static createTextEditorMockWithParagraphs(paragraphs) {
const root = createRoot(paragraphs);
return this.createTextEditorMockWithRoot(root);
}
/**
* Creates an empty TextEditor mock.
*
* @returns {TextEditorMock}
*/
static createTextEditorMockEmpty() {
const root = createRoot([
createParagraph([createTextSpan(createLineBreak())]),
]);
return this.createTextEditorMockWithRoot(root);
}
/**
* Creates a TextEditor mock with some text.
*
* NOTE: If the text is empty an empty text span will be
* created.
*
* @param {string} text
* @returns {TextEditorMock}
*/
static createTextEditorMockWithText(text) {
return this.createTextEditorMockWithParagraphs([
createParagraph([
text.length === 0
? createEmptyTextSpan()
: createTextSpan(new Text(text)),
]),
]);
}
/**
* Creates a TextEditor mock with some textSpans and
* only one paragraph.
*
* @see createTextEditorMockWith
* @param {Array<HTMLSpanElement>} textSpans
* @returns {TextEditorMock}
*/
static createTextEditorMockWithParagraph(textSpans) {
return this.createTextEditorMockWithParagraphs([
createParagraph(textSpans),
]);
}
/**
* Creates a TextEditor mock with some text.
*
* @param {Array<Array<string>>|Array<string>} paragraphs
* @returns {TextEditorMock}
*/
static createTextEditorMockWith(paragraphs) {
const root = createRoot(paragraphs.map((paragraph) => createParagraphWith(paragraph)));
return this.createTextEditorMockWithRoot(root);
}
#element = null;
#root = null;
#selectionImposterElement = null;
/**
* Constructor
*
* @param {HTMLDivElement} element
* @param {*} options
*/
constructor(element, options) {
super();
this.#element = element;
this.#root = options?.root;
this.#selectionImposterElement = options?.selectionImposterElement;
this.#element.dataset.itype = "editor";
this.#element.appendChild(options?.root);
}
get element() {
return this.#element;
}
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;