From 222322dcb5916cfce835bd0ca670b9a3d43b3ef1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 8 Aug 2026 19:39:34 +0000 Subject: [PATCH] :bug: 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 --- .../text-editor/src/editor/TextEditor.test.js | 22 +++++- .../text-editor/src/editor/clipboard/paste.js | 5 ++ .../src/editor/clipboard/paste.test.js | 69 ++++++++++++++++--- .../src/editor/content/dom/Content.js | 6 +- .../src/editor/content/dom/Content.test.js | 13 ++++ 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/frontend/text-editor/src/editor/TextEditor.test.js b/frontend/text-editor/src/editor/TextEditor.test.js index cb78558a16..3bfcc22ae8 100644 --- a/frontend/text-editor/src/editor/TextEditor.test.js +++ b/frontend/text-editor/src/editor/TextEditor.test.js @@ -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( + "
Hello, World!
", + 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("
Hello, World!
"); + expect(root.dataset.itype).toBe("root"); + expect(root.children).toHaveLength(1); + expect(root.firstElementChild.children).toHaveLength(1); + expect(root.textContent).toBe("Hello, World!"); + }); }); diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index ea699349a9..683d40edb0 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -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 { diff --git a/frontend/text-editor/src/editor/clipboard/paste.test.js b/frontend/text-editor/src/editor/clipboard/paste.test.js index 14dd7a9ac0..53658cac7c 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.test.js +++ b/frontend/text-editor/src/editor/clipboard/paste.test.js @@ -5,6 +5,24 @@ import { paste } from "./paste.js"; /* @vitest-environment jsdom */ +/** + * Creates a minimal `ClipboardEvent`-like object carrying the given data. + * + * @param {Object.} 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": "
Hello, World!
", + "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!"); + }); }); diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 0cb742992d..b04dd7636b 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -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); diff --git a/frontend/text-editor/src/editor/content/dom/Content.test.js b/frontend/text-editor/src/editor/content/dom/Content.test.js index 577e41d66b..7480a1642c 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.test.js +++ b/frontend/text-editor/src/editor/content/dom/Content.test.js @@ -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( + "
Hello, World!
", + 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 = [