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/normalizeOfficeHTML.ts b/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts new file mode 100644 index 0000000000..4bd2ad43d4 --- /dev/null +++ b/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts @@ -0,0 +1,155 @@ +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 normalizeWordLists(root: HTMLElement) { + const stack: { + element: HTMLElement; + level: number; + lastItem: HTMLLIElement; + }[] = []; + let listID: string | undefined; + + for (const paragraph of Array.from(root.querySelectorAll("p[style]"))) { + const declaration = getMSOList(paragraph); + const match = declaration?.match(/^l(\d+)\s+level(\d+)\s+lfo(\d+)$/i); + if (!match) { + continue; + } + + const level = Number(match[2]); + const id = `${match[1]}:${match[3]}`; + let previous = paragraph.previousSibling; + while ( + previous && + (previous.nodeType === 8 || + (previous.nodeType === 3 && !previous.textContent?.trim())) + ) { + previous = previous.previousSibling; + } + if (id !== listID || previous !== stack[0]?.element) { + stack.length = 0; + } + listID = id; + + let marker = ""; + const markers: Element[] = []; + for (const span of Array.from(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(); + } + + while (stack.length && stack[stack.length - 1].level > level) { + stack.pop(); + } + 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)) { + // 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.browser.test.ts b/packages/core/src/api/clipboard/fromClipboard/officeHTML.browser.test.ts new file mode 100644 index 0000000000..3af0fd73da --- /dev/null +++ b/packages/core/src/api/clipboard/fromClipboard/officeHTML.browser.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { normalizeOfficeHTML } from "./normalizeOfficeHTML.js"; + +// These tests exercise browser HTML parsing and native clipboard events. +const editors: BlockNoteEditor[] = []; +const containers: HTMLElement[] = []; + +function createEditor( + options: Parameters[0] = {}, +) { + const editor = BlockNoteEditor.create(options); + editors.push(editor); + return editor; +} + +function mountEditor( + options: Parameters[0] = {}, +) { + const editor = createEditor(options); + const container = document.createElement("div"); + document.body.append(container); + containers.push(container); + editor.mount(container); + return editor; +} + +function parseOfficeHTML(html: string) { + return createEditor().tryParseHTMLToBlocks(normalizeOfficeHTML(html)); +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } + for (const container of containers.splice(0)) { + container.remove(); + } +}); + +function wordItem(text: string, level = 1, marker = "·", list = "l0") { + return `

${marker} ${text}

`; +} + +function createPasteEvent(data: Record) { + const event = new ClipboardEvent("paste", { + clipboardData: new DataTransfer(), + bubbles: true, + cancelable: true, + }); + if (!event.clipboardData) { + throw new Error( + "Synthetic paste events must provide a clipboard data store", + ); + } + // Firefox creates its own store rather than retaining the constructor's store. + for (const [type, value] of Object.entries(data)) { + event.clipboardData.setData(type, value); + expect(event.clipboardData.getData(type)).toBe(value); + } + return event; +} + +describe("Office clipboard HTML", () => { + 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); + }); + + it("can replace the default behavior with an existing pasteHandler", () => { + const editor = mountEditor({ + pasteHandler: ({ editor }) => { + editor.pasteHTML("

Replacement

"); + return true; + }, + }); + editor.prosemirrorView.dom.dispatchEvent( + createPasteEvent({ "text/html": wordItem("Original") }), + ); + expect(editor.document[0]).toMatchObject({ + type: "heading", + props: { level: 2 }, + content: [{ text: "Replacement" }], + }); + }); + + it("keeps normalization when a custom pasteHandler delegates to the default", () => { + const editor = mountEditor({ + pasteHandler: ({ defaultPasteHandler }) => defaultPasteHandler(), + }); + editor.prosemirrorView.dom.dispatchEvent( + createPasteEvent({ "text/html": wordItem("Original") }), + ); + expect(editor.document[0]).toMatchObject({ + type: "bulletListItem", + content: [{ text: "Original" }], + }); + }); + + it("normalizes Word HTML in the default clipboard handler", () => { + const editor = mountEditor(); + const html = wordItem("Original"); + editor.prosemirrorView.dom.dispatchEvent( + createPasteEvent({ "text/html": html, "text/plain": "Original" }), + ); + expect(editor.document[0]).toMatchObject({ + type: "bulletListItem", + content: [{ text: "Original" }], + }); + }); + + it("preserves Google Docs blank paragraphs in the default clipboard handler", () => { + const editor = mountEditor(); + editor.prosemirrorView.dom.dispatchEvent( + createPasteEvent({ + "text/html": + '

First


Last

', + }), + ); + expect(editor.document).toMatchObject([ + { type: "paragraph", content: [{ text: "First" }] }, + { type: "paragraph", content: [] }, + { type: "paragraph", content: [{ text: "Last" }] }, + ]); + }); + + it("does not normalize programmatic HTML pastes", () => { + const editor = mountEditor(); + editor.pasteHTML(wordItem("Original")); + expect(editor.document[0]).toMatchObject({ + type: "paragraph", + content: [{ text: "·\u00a0Original" }], + }); + }); + + it("does not preprocess internal BlockNote clipboard HTML", () => { + const editor = mountEditor(); + editor.prosemirrorView.dom.dispatchEvent( + createPasteEvent({ + "blocknote/html": editor.blocksToFullHTML([ + { type: "paragraph", content: "Original" }, + ]), + "text/html": "

Fallback

", + }), + ); + expect(editor.document[0]).toMatchObject({ + content: [{ text: "Original" }], + }); + }); +}); 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; }