🐛 Fix HTML paste never using the pasted HTML structure

The v2-html-paste path was broken since it was introduced. The paste
handler called the formatted/plain dispatcher without the selection
controller, so reading `clipboardData.types` threw on every paste while
the feature was enabled. The HTML branch also never forwarded the
allowHTMLPaste flag to mapContentFragmentFromHTML, and that function
called mapContentFragmentFromDocument() without the root node, so
createNodeIterator() threw and the silent fallback always degraded the
paste to plain text.

Pass the selection controller through the dispatcher, forward the flag
from the formatted fragment helper, and iterate the parsed document from
its body.

AI-assisted-by: longcat-2.0-free
This commit is contained in:
Andrey Antukh 2026-08-08 19:39:34 +00:00
parent b0014d92aa
commit 222322dcb5
5 changed files with 104 additions and 11 deletions

View File

@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { TextEditor } from "./TextEditor.js";
import { TextEditor, createRootFromHTML } from "./TextEditor.js";
/* @vitest-environment jsdom */
describe("TextEditor", () => {
@ -97,4 +97,24 @@ describe("TextEditor", () => {
expect(document.activeElement).toBe(textEditor.element);
expect(selection.containsNode(textEditor.root));
});
test("createRootFromHTML should keep the HTML structure when HTML paste is allowed", () => {
const root = createRootFromHTML(
"<div>Hello, <b>World!</b></div>",
undefined,
true,
);
expect(root.dataset.itype).toBe("root");
expect(root.children).toHaveLength(1);
expect(root.firstElementChild.children).toHaveLength(2);
expect(root.textContent).toBe("Hello, World!");
});
test("createRootFromHTML should flatten to plain text when HTML paste is not allowed", () => {
const root = createRootFromHTML("<div>Hello, <b>World!</b></div>");
expect(root.dataset.itype).toBe("root");
expect(root.children).toHaveLength(1);
expect(root.firstElementChild.children).toHaveLength(1);
expect(root.textContent).toBe("Hello, World!");
});
});

View File

@ -15,6 +15,7 @@ import { TextEditor } from "../TextEditor.js";
/**
* Returns a DocumentFragment from text/html.
*
* @param {SelectionController} selectionController
* @param {DataTransfer} clipboardData
* @returns {DocumentFragment}
*/
@ -25,12 +26,14 @@ function getFormattedFragmentFromClipboardData(
return mapContentFragmentFromHTML(
clipboardData.getData("text/html"),
selectionController.currentStyle,
true,
);
}
/**
* Returns a DocumentFragment from text/plain.
*
* @param {SelectionController} selectionController
* @param {DataTransfer} clipboardData
* @returns {DocumentFragment}
*/
@ -44,6 +47,7 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) {
/**
* Returns a document fragment of html data.
*
* @param {SelectionController} selectionController
* @param {DataTransfer} clipboardData
* @returns {DocumentFragment}
*/
@ -83,6 +87,7 @@ export function paste(event, editor, selectionController) {
let fragment = null;
if (editor?.options?.allowHTMLPaste) {
fragment = getFormattedOrPlainFragmentFromClipboardData(
selectionController,
event.clipboardData,
);
} else {

View File

@ -5,6 +5,24 @@ import { paste } from "./paste.js";
/* @vitest-environment jsdom */
/**
* Creates a minimal `ClipboardEvent`-like object carrying the given data.
*
* @param {Object.<string, string>} data
* @returns {object}
*/
function createClipboardEvent(data) {
return {
preventDefault() {},
clipboardData: {
types: Object.keys(data),
getData(type) {
return data[type] ?? "";
},
},
};
}
/**
* Creates a minimal `ClipboardEvent`-like object carrying plain text.
*
@ -12,15 +30,7 @@ import { paste } from "./paste.js";
* @returns {object}
*/
function createPlainTextClipboardEvent(text) {
return {
preventDefault() {},
clipboardData: {
types: ["text/plain"],
getData(type) {
return type === "text/plain" ? text : "";
},
},
};
return createClipboardEvent({ "text/plain": text });
}
describe("paste", () => {
@ -64,4 +74,45 @@ describe("paste", () => {
expect(root.textContent).toBe("Hello, World!");
});
test("should insert the HTML contents when the editor allows HTML paste", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
textEditorMock.options = { allowHTMLPaste: true };
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
paste(
createClipboardEvent({
"text/html": "<div>Hello, <b>World!</b></div>",
"text/plain": "ignored",
}),
textEditorMock,
selectionController,
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
test("should fall back to plain text when the editor allows HTML paste but there is no HTML", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("");
textEditorMock.options = { allowHTMLPaste: true };
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!");
});
});

View File

@ -207,7 +207,11 @@ export function mapContentFragmentFromHTML(
try {
const parser = new DOMParser();
const document = parser.parseFromString(html, "text/html");
return mapContentFragmentFromDocument(document, styleDefaults);
return mapContentFragmentFromDocument(
document,
document.body,
styleDefaults,
);
} catch (error) {
console.error("Couldn't parse HTML", html, error);
const plainText = htmlToText(html);

View File

@ -43,6 +43,19 @@ describe("Content", () => {
expect(contentFragment.textContent).toBe("Hello, World!");
});
test("mapContentFragmentFromHTML should map the HTML structure when HTML paste is allowed", () => {
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Hello, <b>World!</b></div>",
inertElement.style,
true,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(1);
expect(contentFragment.firstElementChild.children).toHaveLength(2);
expect(contentFragment.textContent).toBe("Hello, World!");
});
/*
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple paragraphs)", () => {
const paragraphs = [