mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 22:40:33 +00:00
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
163 lines
4.0 KiB
JavaScript
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;
|