diff --git a/docs/content/docs/reference/editor/paste-handling.mdx b/docs/content/docs/reference/editor/paste-handling.mdx index 4115afb2cf..1824a17e74 100644 --- a/docs/content/docs/reference/editor/paste-handling.mdx +++ b/docs/content/docs/reference/editor/paste-handling.mdx @@ -21,6 +21,20 @@ BlockNote, by default, attempts to paste content in the following order: You can change the default paste behavior by providing a custom paste handler, which will give you full control over how pasted content is inserted into the editor. +## Word and Google Docs + +BlockNote automatically converts Microsoft Word list paragraphs into nested lists, +removes Word paragraph-end placeholders, and preserves blank paragraphs copied from +Google Docs. This applies to external HTML pasted from the clipboard. + +This is a best-effort conversion into BlockNote's block types, not exact source +formatting preservation. Unsupported list labels, such as `iv.` or `1.2.`, stay as +text instead of being removed. + +To replace this behavior, use a custom `pasteHandler` and handle the HTML before +calling `editor.pasteHTML`. Calling `defaultPasteHandler` keeps the built-in +normalization. Internal BlockNote clipboard content bypasses this normalization. + ## `pasteHandler` option The `pasteHandler` option is a function that receives the following arguments: diff --git a/packages/core/src/api/clipboard/fromClipboard/__snapshots__/officeHTML.test.ts.snap b/packages/core/src/api/clipboard/fromClipboard/__snapshots__/officeHTML.test.ts.snap new file mode 100644 index 0000000000..d50b4f8b2b --- /dev/null +++ b/packages/core/src/api/clipboard/fromClipboard/__snapshots__/officeHTML.test.ts.snap @@ -0,0 +1,558 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Office clipboard HTML > parses captured clipboard HTML: Google Docs mixed lists 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "1", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "numberedListItem", + }, + ], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "A", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "bulletListItem", + }, + ], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "1", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "numberedListItem", + }, + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "2", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "numberedListItem", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "A", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "bulletListItem", + }, + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "B", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "bulletListItem", + }, + ], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "3", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "numberedListItem", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "1", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "numberedListItem", + }, + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "2", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "numberedListItem", + }, + ], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "A", + "type": "text", + }, + ], + "props": { + "backgroundColor": "transparent", + "textAlignment": "left", + "textColor": "rgb(0, 0, 0)", + }, + "type": "bulletListItem", + }, +] +`; + +exports[`Office clipboard HTML > parses captured clipboard HTML: Google Docs paragraphs 1`] = ` +[ + { + "children": [], + "content": [], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "A", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "isToggleable": false, + "level": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "heading", + }, + { + "children": [], + "content": [], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": { + "backgroundColor": "transparent", + "textColor": "rgb(0, 0, 0)", + }, + "text": "B", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Office clipboard HTML > parses captured clipboard HTML: Word formatting 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Line ", + "type": "text", + }, + { + "styles": { + "bold": true, + }, + "text": "bold", + "type": "text", + }, + { + "styles": {}, + "text": " and ", + "type": "text", + }, + { + "styles": { + "italic": true, + }, + "text": "italics", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Line ", + "type": "text", + }, + { + "styles": { + "bold": true, + "underline": true, + }, + "text": "foo", + "type": "text", + }, + { + "styles": { + "underline": true, + }, + "text": " bar", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": { + "strike": true, + }, + "text": "Third", + "type": "text", + }, + { + "styles": {}, + "text": " line ", + "type": "text", + }, + { + "styles": { + "bold": true, + }, + "text": "styling, ", + "type": "text", + }, + { + "styles": { + "bold": true, + "italic": true, + }, + "text": "space on e", + "type": "text", + }, + { + "styles": { + "bold": true, + }, + "text": "nd ", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Office clipboard HTML > parses captured clipboard HTML: Word mixed lists 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "One Asdlkfj alksdfj ", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Two Asdlkfjalksdfj lkasjdf lkasj dflka sdf", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Three sub one Asldkfjalksdfj laksjdf", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Three sub two Alskdfjlaksjfd lkasd f", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + ], + "content": [ + { + "styles": {}, + "text": "Three Alskdfj alksdfj lkasj df", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Four Asldkfjalkfdj lkasd", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Five Alskdfj laksdfjlak jd", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Six sub one Alsdkfj ", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + ], + "content": [ + { + "styles": {}, + "text": "Six Alskdfj laksjdf lak", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Seven asdfasdfadf alksd flaksjdf laksjd alksjdf askljd f", + "type": "text", + }, + ], + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "bulletListItem", + }, +] +`; diff --git a/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts b/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts new file mode 100644 index 0000000000..7bb3c1b2a8 --- /dev/null +++ b/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts @@ -0,0 +1,243 @@ +import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js"; + +// Check the original string: attributes on are lost when parsing a fragment. +const wordHTML = + /urn:schemas-microsoft-com:office:word|]*Word\.Document|mso-list\s*:\s*l\d+\s+level\d+/i; +const googleDocsWrapper = '[id^="docs-internal-guid-"]'; + +// Unknown CSS declarations such as mso-list are discarded by CSSStyleDeclaration. +function getMSOList(element: Element): string | undefined { + return element + .getAttribute("style") + ?.match(/(?:^|;)\s*mso-list\s*:\s*([^;]+)/i)?.[1] + .trim(); +} + +function getWordListInfo(element: Element) { + const match = getMSOList(element)?.match(/^l(\d+)\s+level(\d+)\s+lfo(\d+)$/i); + return match + ? { level: Number(match[2]), id: `${match[1]}:${match[3]}` } + : undefined; +} + +function isWordListWhitespace(node: Node) { + return ( + node.nodeType === 8 || (node.nodeType === 3 && !node.textContent?.trim()) + ); +} + +// Word sometimes wraps a native level-two list in an otherwise empty list. +// Only peel those wrappers when they contain nothing else (including text). +function getWordNativeList(outer: Element) { + let list = outer; + while (true) { + const children = Array.from(list.childNodes).filter( + (node) => !isWordListWhitespace(node), + ); + const child = children[0]; + if ( + children.length !== 1 || + !(child instanceof Element) || + !/^(UL|OL)$/.test(child.tagName) + ) { + break; + } + list = child; + } + const items = Array.from(list.childNodes).filter( + (node) => !isWordListWhitespace(node), + ); + if (!items.every((item) => item instanceof HTMLLIElement)) { + return undefined; + } + const first = items[0]; + const info = first && getWordListInfo(first); + if ( + !info || + !items.every((item) => { + const itemInfo = getWordListInfo(item); + return itemInfo?.id === info.id && itemInfo.level === info.level; + }) + ) { + return undefined; + } + return { list, items, ...info }; +} + +function normalizeWordLists(root: HTMLElement) { + const stack: { + element: Element; + level: number; + lastItem: HTMLLIElement; + }[] = []; + let listID: string | undefined; + + for (const paragraph of root.querySelectorAll("p[style], ul, ol")) { + // Native list content already has its own structure. In particular, do not + // turn a styled paragraph inside an li into a second list item. + if (paragraph.parentElement?.closest("ul, ol, li")) { + continue; + } + const native = paragraph.tagName !== "P"; + const nativeList = native ? getWordNativeList(paragraph) : undefined; + const info = native ? nativeList : getWordListInfo(paragraph); + if (!info) { + continue; + } + const { level, id } = info; + let previous = paragraph.previousSibling; + while (previous && isWordListWhitespace(previous)) { + previous = previous.previousSibling; + } + if (id !== listID || previous !== stack[0]?.element) { + stack.length = 0; + } + listID = id; + + while (stack.length && stack[stack.length - 1].level > level) { + stack.pop(); + } + + if (nativeList) { + const current = stack[stack.length - 1]; + const lastItem = nativeList.items[nativeList.items.length - 1]; + if (current?.level === level) { + if (current.element.tagName !== nativeList.list.tagName) { + stack.length = 0; + continue; + } + current.element.append(...nativeList.list.childNodes); + current.lastItem = lastItem; + paragraph.remove(); + } else if (current && level === current.level + 1) { + current.lastItem.append(nativeList.list); + if (paragraph !== nativeList.list) { + paragraph.remove(); + } + stack.push({ element: nativeList.list, level, lastItem }); + } else if (!current && paragraph === nativeList.list) { + stack.push({ element: nativeList.list, level, lastItem }); + } else { + // A missing parent or a skipped level is not enough evidence to move + // an existing list under an unrelated preceding item. + stack.length = 0; + } + continue; + } + + let marker = ""; + const markers: Element[] = []; + for (const span of paragraph.querySelectorAll("span[style]")) { + if ( + getMSOList(span)?.toLowerCase() === "ignore" && + !markers.some((parent) => parent.contains(span)) + ) { + marker += span.textContent; + markers.push(span); + } + } + marker = marker.trim(); + const ordered = /^(?:[0-9]+[.)]|\([0-9]+\))$/.test(marker); + // Do not discard labels we cannot represent, such as multilevel "1.2.". + // Leave these as paragraphs with their original marker and formatting. + if (!ordered && marker && !/^[·•o▪●○■□‣⁃\-\uF0B7]$/u.test(marker)) { + stack.length = 0; + continue; + } + const tag = ordered ? "ol" : "ul"; + for (const span of markers) { + span.remove(); + } + + if ( + stack[stack.length - 1]?.level === level && + stack[stack.length - 1].element.tagName.toLowerCase() !== tag + ) { + stack.pop(); + } + + const item = root.ownerDocument.createElement("li"); + // Block properties are parsed from the li, while the paragraph below keeps + // its styles for inline-content parsing. + item.setAttribute("style", paragraph.getAttribute("style")!); + let current = stack[stack.length - 1]; + if (!current || current.level !== level) { + const element = root.ownerDocument.createElement(tag); + if (current) { + current.lastItem.append(element); + } else { + paragraph.before(element); + } + current = { element, level, lastItem: item }; + stack.push(current); + } + + current.element.append(item); + current.lastItem = item; + item.append(paragraph); + } +} + +/** + * Normalizes Microsoft Word lists and paragraph ends, and Google Docs blank + * paragraphs. Other HTML is left unchanged. This is not an HTML sanitizer. + */ +export function normalizeOfficeHTML(html: string): string { + const isWord = wordHTML.test(html); + if (!isWord && !html.includes("docs-internal-guid-")) { + return html; + } + + // Keep imported resources inert, as in nestedListsToBlockNoteStructure. + const document = globalThis.document.implementation.createHTMLDocument(""); + const root = document.createElement("div"); + root.innerHTML = html; + + if (isWord) { + for (const end of Array.from(root.getElementsByTagName("o:p"))) { + const paragraph = end.closest("p"); + end.remove(); + if ( + paragraph && + !paragraph.textContent?.trim() && + !paragraph.querySelector( + "br, img, svg, video, audio, iframe, object, input", + ) + ) { + paragraph.append(EMPTY_BLOCK_PLACEHOLDER); + } + } + normalizeWordLists(root); + } + + for (const wrapper of root.querySelectorAll(googleDocsWrapper)) { + // Firefox can put the source ID on the selected paragraph itself. Its BRs + // are hard breaks, not sibling paragraphs. Only fragment containers can + // expand direct BRs into paragraphs (never textblocks or inline spans). + if ( + !wrapper.matches("b, div") || + wrapper.parentElement?.closest("p, h1, h2, h3, h4, h5, h6, pre") + ) { + continue; + } + // Direct BRs represent blank paragraphs only in block-level fragments. + if ( + Array.from(wrapper.childNodes).some( + (node) => + (node.nodeType === 3 && !!node.textContent?.trim()) || + (node.nodeType === 1 && + !/^(BR|P|DIV|H[1-6]|UL|OL|TABLE|BLOCKQUOTE|PRE|HR|FIGURE)$/.test( + node.nodeName, + )), + ) + ) { + continue; + } + for (const child of wrapper.querySelectorAll(":scope > br")) { + const paragraph = document.createElement("p"); + paragraph.textContent = EMPTY_BLOCK_PLACEHOLDER; + child.replaceWith(paragraph); + } + } + return root.innerHTML; +} diff --git a/packages/core/src/api/clipboard/fromClipboard/officeHTML.test.ts b/packages/core/src/api/clipboard/fromClipboard/officeHTML.test.ts new file mode 100644 index 0000000000..18d5bb4d92 --- /dev/null +++ b/packages/core/src/api/clipboard/fromClipboard/officeHTML.test.ts @@ -0,0 +1,567 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { normalizeOfficeHTML } from "./normalizeOfficeHTML.js"; + +// DOM parsing tests run in the core package's jsdom environment. +const editors: BlockNoteEditor[] = []; + +function createEditor( + options: Parameters[0] = {}, +) { + const editor = BlockNoteEditor.create(options); + editors.push(editor); + return editor; +} + +function parseOfficeHTML(html: string) { + return createEditor().tryParseHTMLToBlocks(normalizeOfficeHTML(html)); +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } +}); + +function wordItem(text: string, level = 1, marker = "·", list = "l0") { + return `

${marker} ${text}

`; +} + +describe("Office clipboard HTML", () => { + // Clipboard samples from CKEditor 5's paste-from-office/tests/_data at + // https://github.com/ckeditor/ckeditor5/tree/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5-paste-from-office/tests/_data + // Word captures omit the generated head except for source identification. + // Keep fragment whitespace intact. Snapshots omit only generated block IDs. + // Copyright (c) 2003-2026, CKSource Holding sp. z o.o. + // Upstream license notice (GPL-2.0-or-later or commercial): + // https://github.com/ckeditor/ckeditor5/blob/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5/LICENSE.md + it.each([ + [ + "Word formatting", + // basic-styles/multiple-styles-multiline/input.word2016.html + ` + + +

Line bold and italics

+ +

Line foo bar

+ +

Third line styling, + space on end

+ + +`, + ], + [ + "Word mixed lists", + // list/mixed-elements/input.word.html + ` + +

·     +One +Asdlkfj alksdfj

+ +

·     +Two +Asdlkfjalksdfj lkasjdf lkasj dflka sdf

+ +

·     +Three +Alskdfj alksdfj lkasj df

+ + + +

·     +Four +Asldkfjalkfdj lkasd

+ +

·     +Five +Alskdfj laksdfjlak jd

+ +

·     +Six +Alskdfj laksjdf lak

+ +

o  Six sub one Alsdkfj

+ +

·     +Seven +asdfasdfadf alksd flaksjdf laksjd alksjdf askljd f

+ + +`, + ], + [ + "Google Docs paragraphs", + // paste-from-google-docs/br-paragraph/simple-paragraphs/input.html + `

A


B


+`, + ], + [ + "Google Docs mixed lists", + // paste-from-google-docs/lists/mixed-list/input.html + `
  1. 1

    • A

      1. 1

  2. 2

  3. 3

    • A

    • B

  • A

    1. 1

    2. 2

+`, + ], + ])("parses captured clipboard HTML: %s", (_name, html) => { + const blocks = parseOfficeHTML(html); + + function withoutIds(blocks: ReturnType): object[] { + return blocks.map(({ id: _id, children, ...block }) => ({ + ...block, + children: withoutIds(children), + })); + } + + expect(withoutIds(blocks)).toMatchSnapshot(); + }); + + it("reconstructs Word lists, retaining formatting and nesting", () => { + const blocks = parseOfficeHTML( + wordItem("First").replace( + 'style="mso-list:', + 'style="text-align:center;mso-list:', + ) + + wordItem("Nested", 2, "1.") + + wordItem("Second"), + ); + expect(blocks).toMatchObject([ + { + type: "bulletListItem", + props: { textAlignment: "center" }, + content: [{ text: "First", styles: { bold: true } }], + children: [ + { + type: "numberedListItem", + content: [{ text: "Nested" }], + }, + ], + }, + { type: "bulletListItem", content: [{ text: "Second" }], children: [] }, + ]); + expect(blocks).toHaveLength(2); + }); + + it("separates Word lists across paragraphs, list IDs and marker types", () => { + const blocks = parseOfficeHTML( + wordItem("One", 1, "4.") + + "

Between

" + + wordItem("Two", 1, "7.") + + wordItem("Bullet") + + wordItem("New list", 1, "2.", "l1") + + wordItem("Another list", 2, "1.", "l2"), + ); + expect(blocks.map((block) => block.type)).toEqual([ + "numberedListItem", + "paragraph", + "numberedListItem", + "bulletListItem", + "numberedListItem", + "numberedListItem", + ]); + expect(blocks).toHaveLength(6); + expect(blocks[4].children).toEqual([]); + }); + + it("removes Word paragraph-end placeholders without removing empty paragraphs", () => { + const blocks = parseOfficeHTML( + '

Text 

 

', + ); + expect(blocks).toMatchObject([ + { type: "paragraph", content: [{ text: "Text" }] }, + { type: "paragraph", content: [] }, + ]); + expect(blocks).toHaveLength(2); + }); + + it("parses nested numbered lists", () => { + const blocks = parseOfficeHTML( + wordItem("Parent", 1, "1.") + + wordItem("Child", 2, "1.") + + wordItem("Next", 1, "2."), + ); + expect(blocks).toMatchObject([ + { + type: "numberedListItem", + content: [{ text: "Parent" }], + children: [{ type: "numberedListItem", content: [{ text: "Child" }] }], + }, + { type: "numberedListItem", content: [{ text: "Next" }] }, + ]); + expect(blocks).toHaveLength(2); + }); + + it("recognizes parenthesized numbers and retains unsupported Word labels", () => { + const blocks = parseOfficeHTML( + wordItem("Number", 1, "(3)") + + wordItem("Multilevel", 1, "1.2.") + + wordItem("Unknown", 1, "Section A:") + + wordItem("Roman", 1, "iv.") + + wordItem("Letter", 1, "c."), + ); + expect(blocks).toMatchObject([ + { + type: "numberedListItem", + content: [{ text: "Number" }], + }, + { type: "paragraph", content: [{ text: "1.2.\u00a0Multilevel" }] }, + { type: "paragraph", content: [{ text: "Section A:\u00a0Unknown" }] }, + { type: "paragraph", content: [{ text: "iv.\u00a0Roman" }] }, + { type: "paragraph", content: [{ text: "c.\u00a0Letter" }] }, + ]); + }); + + it("does not move intervening text into a Word list", () => { + const blocks = parseOfficeHTML( + wordItem("First") + "Between" + wordItem("Second"), + ); + expect(blocks).toMatchObject([ + { type: "bulletListItem", content: [{ text: "First" }] }, + { type: "paragraph", content: [{ text: "Between" }] }, + { type: "bulletListItem", content: [{ text: "Second" }] }, + ]); + expect(blocks).toHaveLength(3); + }); + + it("preserves Google Docs blank paragraphs but leaves inline breaks alone", () => { + const blocks = parseOfficeHTML( + '

First
line

\n
\n
\n

Last

', + ); + expect(blocks).toMatchObject([ + { type: "paragraph", content: [{ text: "First\nline", styles: {} }] }, + { type: "paragraph", content: [] }, + { type: "paragraph", content: [] }, + { type: "paragraph", content: [{ text: "Last", styles: {} }] }, + ]); + expect(blocks).toHaveLength(4); + }); + + it("leaves programmatic HTML imports unchanged", () => { + const blocks = createEditor().tryParseHTMLToBlocks(wordItem("Unchanged")); + expect(blocks).toMatchObject([ + { type: "paragraph", content: [{ text: "·\u00a0Unchanged" }] }, + ]); + }); + + it.each([ + ["First
Last", "First\nLast"], + ["First


Last", "First\n\n\nLast"], + ["First
Last", "First\nLast"], + ["First \n
\n Last", "First \nLast"], + ])( + "keeps hard breaks in Google Docs inline fragments: %s", + (fragment, text) => { + const blocks = parseOfficeHTML( + `${fragment}`, + ); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "paragraph", + content: [{ text }], + }); + }, + ); + + it("leaves unrelated HTML unchanged", () => { + const html = "

Original


Last

"; + expect(normalizeOfficeHTML(html)).toBe(html); + }); +}); + +function normalizedDOM(html: string) { + const root = document.createElement("div"); + root.innerHTML = normalizeOfficeHTML(html); + return root; +} + +function paragraph(text: string, level = 1, list = "l0", lfo = 1) { + return `

· ${text}

`; +} + +function nativeItem(text: string, level = 2, list = "l0", lfo = 1) { + return `
  • ${text}
  • `; +} + +function expectedItem( + text: string, + children: object[] = [], + type = "bulletListItem", +) { + return { type, content: [{ text }], children }; +} + +describe("Word clipboard list transitions", () => { + // Reduced from the captured list/mixed-elements/input.word.html sample above: + // a level-one paragraph followed by
        whose li elements explicitly + // identify the same list and level two. + it("attaches wrapped native subitems to the preceding paragraph item", () => { + const blocks = parseOfficeHTML( + paragraph("One") + + paragraph("Three") + + `
          \n
            ${nativeItem("Three sub one")}${nativeItem("Three sub two")}
          \n
        ` + + paragraph("Four"), + ); + expect(blocks).toHaveLength(3); + expect(blocks).toMatchObject([ + expectedItem("One"), + expectedItem("Three", [ + expectedItem("Three sub one"), + expectedItem("Three sub two"), + ]), + expectedItem("Four"), + ]); + }); + + it("continues native siblings and lets later paragraphs nest under the last one", () => { + const blocks = parseOfficeHTML( + paragraph("First") + + `
          ${nativeItem("Second", 1)}${nativeItem("Third", 1)}
        ` + + paragraph("Third child", 2) + + paragraph("Last"), + ); + expect(blocks).toHaveLength(4); + expect(blocks).toMatchObject([ + expectedItem("First"), + expectedItem("Second"), + expectedItem("Third", [expectedItem("Third child")]), + expectedItem("Last"), + ]); + }); + + it("uses an existing native item as the parent of a following paragraph item", () => { + const blocks = parseOfficeHTML( + `
          ${nativeItem("Parent", 1)}
        ` + + paragraph("Child", 2) + + paragraph("Sibling"), + ); + expect(blocks).toHaveLength(2); + expect(blocks).toMatchObject([ + expectedItem("Parent", [expectedItem("Child")], "numberedListItem"), + expectedItem("Sibling"), + ]); + }); + + it("preserves native ordered child lists and inline formatting", () => { + const blocks = parseOfficeHTML( + paragraph("Parent") + + `
            ${nativeItem("Child")}
        ` + + paragraph("Grandchild", 3) + + paragraph("Next child", 2), + ); + expect(blocks).toHaveLength(1); + expect(blocks).toMatchObject([ + expectedItem("Parent", [ + { + ...expectedItem( + "Child", + [expectedItem("Grandchild")], + "numberedListItem", + ), + content: [{ text: "Child", styles: { bold: true } }], + }, + expectedItem("Next child"), + ]), + ]); + }); + + it.each([ + ["different list", `
            ${nativeItem("Child", 2, "l1")}
        `], + [ + "different instance", + `
            ${nativeItem("Child", 2, "l0", 2)}
        `, + ], + ["skipped level", `
            ${nativeItem("Child", 3)}
        `], + ["missing metadata", "
          • Child
        "], + [ + "inconsistent metadata", + `
            ${nativeItem("Child")}${nativeItem("Other", 2, "l1")}
        `, + ], + ["wrapper text", `
          Keep me
            ${nativeItem("Child")}
        `], + ])("does not guess a native parent with %s", (_name, native) => { + const root = normalizedDOM(paragraph("Parent") + native); + expect(root.querySelectorAll(":scope > ul")).toHaveLength(2); + expect(root.firstElementChild?.querySelector("li > ul")).toBeNull(); + // The uncertain native fragment is preserved, rather than flattened here. + const original = document.createElement("div"); + original.innerHTML = native; + expect(root.lastElementChild?.outerHTML).toBe(original.innerHTML); + }); + + it.each(["

        Boundary

        ", "Boundary", "

         

        "])( + "does not attach native subitems across a content boundary: %s", + (boundary) => { + const root = normalizedDOM( + paragraph("Parent") + + boundary + + `
            ${nativeItem("Child")}
        ` + + paragraph("Last"), + ); + expect(root.querySelectorAll(":scope > ul")).toHaveLength(3); + expect(root.firstElementChild?.querySelector("li > ul")).toBeNull(); + expect(root.lastElementChild?.textContent).toBe("Last"); + }, + ); + + it("keeps transitions local to a table cell", () => { + const root = normalizedDOM( + `
        ${paragraph("Cell parent")}
            ${nativeItem("Cell child")}
            ${nativeItem("Other cell")}
        ${paragraph("Other parent")}
        ` + + paragraph("Outside"), + ); + const cells = root.querySelectorAll("td"); + expect(cells[0].querySelectorAll(":scope > ul")).toHaveLength(1); + expect(cells[0].querySelector("li > ul > li")?.textContent).toBe( + "Cell child", + ); + expect(cells[1].querySelectorAll(":scope > ul")).toHaveLength(2); + expect(root.querySelector(":scope > ul")?.textContent).toBe("Outside"); + expect(root.textContent).toBe( + "Cell parentCell childOther cellOther parentOutside", + ); + }); + + it("does not create a second list around a native item's styled paragraph", () => { + const html = `
          ${nativeItem('

          Content

          ', 1)}
        `; + const root = normalizedDOM(html); + expect(root.querySelectorAll("li")).toHaveLength(1); + expect(root.querySelector("li > ul")).toBeNull(); + expect(root.textContent).toBe("Content"); + expect(parseOfficeHTML(html)).toMatchObject([expectedItem("Content")]); + }); +}); + +describe("Google Docs clipboard normalization", () => { + // Source shapes are reduced from the repository's googleDocs parse fixture + // (tests/src/unit/core/formatConversion/parse/parseTestInstances.ts), plus + // CKEditor 5's captured Firefox paragraph wrapper: + // https://github.com/ckeditor/ckeditor5/blob/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5-paste-from-office/tests/_data/paste-from-google-docs/bold-wrapper/simple-text/input.firefox.html + // Copyright (c) 2003-2026, CKSource Holding sp. z o.o. + // Upstream license (GPL-2.0-or-later or commercial): + // https://github.com/ckeditor/ckeditor5/blob/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5/LICENSE.md + // Break-only selections below are synthetic boundary cases using those shapes. + it.each(["p", "h1", "span"])( + "preserves hard breaks in a source-marked %s without adding paragraphs", + (tag) => { + const blocks = parseOfficeHTML( + `<${tag} id="docs-internal-guid-test">

        `, + ); + expect(blocks).toMatchObject([ + { + type: tag === "h1" ? "heading" : "paragraph", + ...(tag === "h1" ? { props: { level: 1 } } : {}), + content: [{ text: "\n\n", styles: {} }], + children: [], + }, + ]); + expect(blocks).toHaveLength(1); + }, + ); + + it("keeps a Firefox paragraph wrapper's inline styles and hard breaks", () => { + const blocks = parseOfficeHTML( + '

        Plain
        Bold italic

        ', + ); + expect(blocks).toMatchObject([ + { + type: "paragraph", + content: [ + { text: "Plain\n", styles: {} }, + { text: "Bold italic", styles: { bold: true, italic: true } }, + ], + children: [], + }, + ]); + expect(blocks).toHaveLength(1); + }); + + it("keeps hard breaks when a source wrapper is inside a paragraph", () => { + const blocks = parseOfficeHTML( + '



        ', + ); + expect(blocks).toMatchObject([ + { + type: "paragraph", + content: [{ text: "\n\n", styles: {} }], + children: [], + }, + ]); + expect(blocks).toHaveLength(1); + }); + + it("preserves blank paragraphs around lists and tables without expanding their hard breaks", () => { + const blocks = parseOfficeHTML( + '
        • Bullet
          line

          1. Number

        Cell
        line


        ', + ); + expect(blocks).toMatchObject([ + { type: "paragraph", content: [] }, + { + type: "bulletListItem", + content: [{ text: "Bullet\nline", styles: {} }], + children: [ + { + type: "numberedListItem", + content: [{ text: "Number", styles: {} }], + children: [], + }, + ], + }, + { type: "paragraph", content: [] }, + { + type: "table", + content: { + rows: [ + { cells: [{ content: [{ text: "Cell\nline", styles: {} }] }] }, + ], + }, + }, + { type: "paragraph", content: [] }, + ]); + expect(blocks).toHaveLength(5); + }); +}); diff --git a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts index 91c1f60fe6..3671559cec 100644 --- a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts +++ b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts @@ -14,6 +14,7 @@ import { import { acceptedMIMETypes } from "./acceptedMIMETypes.js"; import { handleFileInsertion } from "./handleFileInsertion.js"; import { handleVSCodePaste } from "./handleVSCodePaste.js"; +import { normalizeOfficeHTML } from "./normalizeOfficeHTML.js"; function defaultPasteHandler({ event, @@ -95,7 +96,7 @@ function defaultPasteHandler({ } if (format === "text/html") { - editor.pasteHTML(data); + editor.pasteHTML(normalizeOfficeHTML(data)); return true; }