From 84041d4d62deaa4c8f88830bdd3b549ad7dc8368 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 22 Oct 2026 04:09:03 +0200 Subject: [PATCH 1/4] feat(core): normalize Word and Google Docs HTML imports --- .../docs/reference/editor/paste-handling.mdx | 27 +++ .../parsers/html/officeHTML.browser.test.ts | 180 ++++++++++++++++++ .../core/src/api/parsers/html/parseHTML.ts | 11 +- .../src/api/parsers/html/preprocessHTML.ts | 139 ++++++++++++++ packages/core/src/editor/BlockNoteEditor.ts | 17 +- .../core/src/editor/managers/ExportManager.ts | 8 +- packages/core/src/index.ts | 1 + .../parse/__snapshots__/html/googleDocs.json | 9 +- .../parse/__snapshots__/html/msWordPaste.json | 8 +- 9 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/api/parsers/html/officeHTML.browser.test.ts create mode 100644 packages/core/src/api/parsers/html/preprocessHTML.ts diff --git a/docs/content/docs/reference/editor/paste-handling.mdx b/docs/content/docs/reference/editor/paste-handling.mdx index 4115afb2cf..80623542da 100644 --- a/docs/content/docs/reference/editor/paste-handling.mdx +++ b/docs/content/docs/reference/editor/paste-handling.mdx @@ -21,6 +21,33 @@ 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 also applies when importing HTML with `tryParseHTMLToBlocks` or +inserting it with `pasteHTML`. + +### Custom HTML preprocessing + +Use the `preprocessHTML` editor option to replace the source normalization. The +function receives the original HTML string and returns the HTML to parse. To extend +the built-in handling, call `defaultHTMLPreprocessor` inside your function: + +```ts +import { BlockNoteEditor, defaultHTMLPreprocessor } from "@blocknote/core"; + +const editor = BlockNoteEditor.create({ + preprocessHTML(html) { + return defaultHTMLPreprocessor(html).replaceAll("Old label", "New label"); + }, +}); +``` + +Set `preprocessHTML: false` to disable Word and Google Docs normalization. Neither +the default nor custom preprocessing runs for internal BlockNote clipboard HTML or +`pasteHTML(html, true)`. Preprocessing is not an HTML sanitizer. + ## `pasteHandler` option The `pasteHandler` option is a function that receives the following arguments: diff --git a/packages/core/src/api/parsers/html/officeHTML.browser.test.ts b/packages/core/src/api/parsers/html/officeHTML.browser.test.ts new file mode 100644 index 0000000000..242ff8d408 --- /dev/null +++ b/packages/core/src/api/parsers/html/officeHTML.browser.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { defaultHTMLPreprocessor } from "./preprocessHTML.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; +} + +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}

`; +} + +describe("Office HTML parsing", () => { + it("reconstructs Word lists, retaining formatting and nesting", () => { + const blocks = createEditor().tryParseHTMLToBlocks( + wordItem("First").replace( + 'style="mso-list:', + 'style="text-align:center;mso-list:', + ) + + wordItem("Nested", 2, "3.") + + wordItem("Second"), + ); + expect(blocks).toMatchObject([ + { + type: "bulletListItem", + props: { textAlignment: "center" }, + content: [{ text: "First", styles: { bold: true } }], + children: [ + { + type: "numberedListItem", + props: { start: 3 }, + 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 = createEditor().tryParseHTMLToBlocks( + wordItem("One", 1, "4.") + + "

Between

" + + wordItem("Two", 1, "7.") + + wordItem("Bullet") + + wordItem("New list", 1, "2.", "l1") + + wordItem("Another list", 1, "8.", "l2"), + ); + expect(blocks.map((block) => block.type)).toEqual([ + "numberedListItem", + "paragraph", + "numberedListItem", + "bulletListItem", + "numberedListItem", + "numberedListItem", + ]); + expect(blocks[0].props).toMatchObject({ start: 4 }); + expect(blocks[2].props).toMatchObject({ start: 7 }); + expect(blocks[4].props).toMatchObject({ start: 2 }); + expect(blocks[5].props).toMatchObject({ start: 8 }); + }); + + it("removes Word paragraph-end placeholders without removing empty paragraphs", () => { + const blocks = createEditor().tryParseHTMLToBlocks( + '

Text 

 

', + ); + expect(blocks).toMatchObject([ + { type: "paragraph", content: [{ text: "Text" }] }, + { type: "paragraph", content: [] }, + ]); + expect(blocks).toHaveLength(2); + }); + + it("preserves Google Docs blank paragraphs but leaves inline breaks alone", () => { + const blocks = createEditor().tryParseHTMLToBlocks( + '

First
line



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("can disable the built-in normalization", () => { + const blocks = createEditor({ preprocessHTML: false }).tryParseHTMLToBlocks( + wordItem("Unchanged"), + ); + expect(blocks).toMatchObject([ + { type: "paragraph", content: [{ text: "·\u00a0Unchanged" }] }, + ]); + }); + + it("can replace the built-in normalization", () => { + const preprocessHTML = vi.fn(() => "

Replacement

"); + const html = wordItem("Original"); + const blocks = createEditor({ preprocessHTML }).tryParseHTMLToBlocks(html); + expect(preprocessHTML).toHaveBeenCalledExactlyOnceWith(html); + expect(blocks).toMatchObject([ + { + type: "heading", + props: { level: 2 }, + content: [{ text: "Replacement" }], + }, + ]); + }); + + it("can extend the built-in normalization", () => { + const editor = createEditor({ + preprocessHTML(html) { + return defaultHTMLPreprocessor(html).replace("Original", "Changed"); + }, + }); + expect(editor.tryParseHTMLToBlocks(wordItem("Original"))).toMatchObject([ + { type: "bulletListItem", content: [{ text: "Changed" }] }, + ]); + }); + + it("uses the same preprocessing for clipboard HTML", () => { + const preprocessHTML = vi.fn((html: string) => + defaultHTMLPreprocessor(html).replace("Original", "Changed"), + ); + const editor = createEditor({ preprocessHTML }); + const container = document.createElement("div"); + document.body.append(container); + containers.push(container); + editor.mount(container); + const html = wordItem("Original"); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/html", html); + clipboardData.setData("text/plain", "Original"); + editor.prosemirrorView.dom.dispatchEvent( + new ClipboardEvent("paste", { + clipboardData, + bubbles: true, + cancelable: true, + }), + ); + expect(preprocessHTML).toHaveBeenCalledExactlyOnceWith(html); + expect(editor.document[0]).toMatchObject({ + type: "bulletListItem", + content: [{ text: "Changed" }], + }); + }); + + it("does not preprocess raw HTML pastes", () => { + const preprocessHTML = vi.fn(() => "

Changed

"); + const editor = createEditor({ preprocessHTML }); + const container = document.createElement("div"); + document.body.append(container); + containers.push(container); + editor.mount(container); + editor.pasteHTML("

Original

", true); + expect(preprocessHTML).not.toHaveBeenCalled(); + expect(editor.document[0]).toMatchObject({ + content: [{ text: "Original" }], + }); + }); +}); diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts index 86243d5be9..15f036ba4d 100644 --- a/packages/core/src/api/parsers/html/parseHTML.ts +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -8,6 +8,7 @@ import { import { Block } from "../../../blocks/defaultBlocks.js"; import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; +import { defaultHTMLPreprocessor, HTMLPreprocessor } from "./preprocessHTML.js"; import { nestedListsToBlockNoteStructure } from "./util/nestedLists.js"; import { preprocessHTMLWhitespace } from "./util/normalizeWhitespace.js"; @@ -55,8 +56,14 @@ export function HTMLToBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, ->(html: string, pmSchema: Schema): Block[] { - const htmlNode = nestedListsToBlockNoteStructure(html); +>( + html: string, + pmSchema: Schema, + preprocessHTML: HTMLPreprocessor | false = defaultHTMLPreprocessor, +): Block[] { + const htmlNode = nestedListsToBlockNoteStructure( + preprocessHTML === false ? html : preprocessHTML(html), + ); preprocessHTMLWhitespace(htmlNode); const parser = DOMParser.fromSchema(pmSchema); diff --git a/packages/core/src/api/parsers/html/preprocessHTML.ts b/packages/core/src/api/parsers/html/preprocessHTML.ts new file mode 100644 index 0000000000..ae8cc77f92 --- /dev/null +++ b/packages/core/src/api/parsers/html/preprocessHTML.ts @@ -0,0 +1,139 @@ +import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js"; + +/** Transforms source HTML before BlockNote parses its blocks and inline content. */ +export type HTMLPreprocessor = (html: string) => string; + +// 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]}`; + if ( + id !== listID || + paragraph.previousElementSibling !== stack[0]?.element + ) { + stack.length = 0; + } + listID = id; + + let marker = ""; + for (const span of Array.from(paragraph.querySelectorAll("span[style]"))) { + if (getMSOList(span)?.toLowerCase() === "ignore") { + marker += span.textContent; + span.remove(); + } + } + marker = marker.trim(); + const tag = /^(?:\d+|[a-z]+)[.)]$/i.test(marker) ? "ol" : "ul"; + + 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. + const style = paragraph.getAttribute("style"); + if (style) { + item.setAttribute("style", style); + } + let current = stack[stack.length - 1]; + if (!current || current.level !== level) { + const element = root.ownerDocument.createElement(tag); + const start = Number.parseInt(marker, 10); + if (tag === "ol" && start > 1) { + element.setAttribute("start", String(start)); + } + 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. + * + * Call this from a custom `preprocessHTML` callback to extend the built-in behavior. + */ +export function defaultHTMLPreprocessor(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)) { + for (const child of Array.from(wrapper.children)) { + if (child.tagName === "BR") { + const paragraph = document.createElement("p"); + // The schema drops empty paragraphs; use the same temporary placeholder + // as the HTML exporter. HTMLToBlocks removes it after parsing. + paragraph.textContent = EMPTY_BLOCK_PLACEHOLDER; + child.replaceWith(paragraph); + } + } + } + return root.innerHTML; +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ddda8786f3..dbbee9ad1e 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -10,6 +10,7 @@ import { Node, Schema } from "prosemirror-model"; import type { BlockPlacement } from "../api/getBlockInfoFromPos.js"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; +import type { HTMLPreprocessor } from "../api/parsers/html/preprocessHTML.js"; import { Block, BlockNoteSchema, @@ -246,6 +247,17 @@ export interface BlockNoteEditorOptions< }) => boolean | undefined; }) => boolean | undefined; + /** + * Transforms HTML before parsing in `tryParseHTMLToBlocks` and `pasteHTML`, + * including clipboard pastes. Defaults to `defaultHTMLPreprocessor`, which + * normalizes Microsoft Word and Google Docs HTML. Set to `false` to disable + * source normalization, or provide a function to replace it. Import and call + * `defaultHTMLPreprocessor` from your function to extend the defaults. + * + * Not applied to `pasteHTML(html, true)` or internal BlockNote clipboard HTML. + */ + preprocessHTML?: HTMLPreprocessor | false; + /** * Resolve a URL of a file block to one that can be displayed or downloaded. This can be used for creating authenticated URL or * implementing custom protocols / schemes @@ -620,7 +632,10 @@ export class BlockNoteEditor< // Initialize managers this._blockManager = new BlockManager(this as any); - this._exportManager = new ExportManager(this as any); + this._exportManager = new ExportManager( + this as any, + options.preprocessHTML, + ); this._selectionManager = new SelectionManager(this as any); this._stateManager = new StateManager(this as any); this._styleManager = new StyleManager(this as any); diff --git a/packages/core/src/editor/managers/ExportManager.ts b/packages/core/src/editor/managers/ExportManager.ts index 3fe1ee2f0e..32095d468c 100644 --- a/packages/core/src/editor/managers/ExportManager.ts +++ b/packages/core/src/editor/managers/ExportManager.ts @@ -2,6 +2,7 @@ import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTM import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer.js"; import { blocksToMarkdown } from "../../api/exporters/markdown/markdownExporter.js"; import { HTMLToBlocks } from "../../api/parsers/html/parseHTML.js"; +import { HTMLPreprocessor } from "../../api/parsers/html/preprocessHTML.js"; import { markdownToBlocks, markdownToHTML, @@ -25,7 +26,10 @@ export class ExportManager< ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema, > { - constructor(private editor: BlockNoteEditor) {} + constructor( + private editor: BlockNoteEditor, + private preprocessHTML?: HTMLPreprocessor | false, + ) {} /** * Exports blocks into a simplified HTML string. To better conform to HTML standards, children of blocks which aren't list @@ -73,7 +77,7 @@ export class ExportManager< public tryParseHTMLToBlocks( html: string, ): Block[] { - return HTMLToBlocks(html, this.editor.pmSchema); + return HTMLToBlocks(html, this.editor.pmSchema, this.preprocessHTML); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a1fda3424..119b8fc26b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,6 +59,7 @@ export * from "./api/nodeConversions/nodeToBlock.js"; // for server-util (TODO: maybe move): export * from "./api/exporters/markdown/markdownExporter.js"; export * from "./api/parsers/html/parseHTML.js"; +export * from "./api/parsers/html/preprocessHTML.js"; export * from "./api/parsers/markdown/parseMarkdown.js"; // TODO: for ai, remove? diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json index 801547eba0..fa582e4a8f 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json @@ -471,14 +471,7 @@ Hard Break", }, { "children": [], - "content": [ - { - "styles": {}, - "text": " -", - "type": "text", - }, - ], + "content": [], "id": "21", "props": { "backgroundColor": "default", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json index b7eb0a7de4..798d251ccb 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json @@ -89,13 +89,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": " ", - "type": "text", - }, - ], + "content": [], "id": "6", "props": { "backgroundColor": "default", From 14925e4ec379df7ae9cb3253f1d5cb45bd2737a1 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 22 Oct 2026 04:54:25 +0200 Subject: [PATCH 2/4] fix(core): preserve Office paste numbering and inline breaks --- .../docs/reference/editor/paste-handling.mdx | 3 + .../commands/splitBlock/splitBlock.ts | 11 +- .../exporters/html/internalHTMLSerializer.ts | 8 +- .../html/util/serializeBlocksExternalHTML.ts | 17 +-- .../parsers/html/officeHTML.browser.test.ts | 138 +++++++++++++++++- .../src/api/parsers/html/preprocessHTML.ts | 74 +++++++++- .../IndexingPlugin.node.test.ts | 109 ++++++++++++++ .../NumberedListItem/IndexingPlugin.ts | 18 ++- .../blocks/ListItem/NumberedListItem/block.ts | 11 +- 9 files changed, 353 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts diff --git a/docs/content/docs/reference/editor/paste-handling.mdx b/docs/content/docs/reference/editor/paste-handling.mdx index 80623542da..279956d0d4 100644 --- a/docs/content/docs/reference/editor/paste-handling.mdx +++ b/docs/content/docs/reference/editor/paste-handling.mdx @@ -28,6 +28,9 @@ removes Word paragraph-end placeholders, and preserves blank paragraphs copied f Google Docs. This also applies when importing HTML with `tryParseHTMLToBlocks` or inserting it with `pasteHTML`. +Word list numbering retains numeric starts, gaps, and restarts. Labels that +BlockNote cannot represent, such as `1.2.`, stay as text instead of being removed. + ### Custom HTML preprocessing Use the `preprocessHTML` editor option to replace the source normalization. The diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 96b82dbd22..7f399fee7e 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -59,7 +59,16 @@ export const splitBlockTr = ( }, { type: keepType ? info.content.node.type : schema.nodes["paragraph"], - attrs: keepProps ? { ...info.content.node.attrs } : {}, + attrs: keepProps + ? { + ...info.content.node.attrs, + // The second half continues the sequence, rather than copying a + // numbering restart from the original item. + ...(info.blockNoteType === "numberedListItem" && { + start: undefined, + }), + } + : {}, }, ]; diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 05338f5f97..598b3be3ef 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -24,11 +24,9 @@ const addIndexToNumberedListItems = (element: HTMLElement) => { '[data-content-type="numberedListItem"]', ); - if (!prevNumberedListItem) { - numberedListItem.setAttribute( - "data-index", - numberedListItem.getAttribute("data-start") || "1", - ); + const start = numberedListItem.getAttribute("data-start"); + if (start !== null || !prevNumberedListItem) { + numberedListItem.setAttribute("data-index", start ?? "1"); } else { const prevNumberedListItemIndex = prevNumberedListItem.getAttribute("data-index"); diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 0a267d1b43..44ad3fefd2 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -391,17 +391,16 @@ function serializeBlock< } if (listType) { - if (fragment.lastChild?.nodeName !== listType) { + const start = "start" in props ? props.start : undefined; + const hasStart = typeof start === "number" && Number.isFinite(start); + if ( + fragment.lastChild?.nodeName !== listType || + (listType === "OL" && hasStart) + ) { const list = doc.createElement(listType); - if ( - listType === "OL" && - "start" in props && - props.start && - props?.start !== 1 - ) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - list.setAttribute("start", String(props.start)); + if (listType === "OL" && hasStart) { + list.setAttribute("start", String(start)); } fragment.append(list); } diff --git a/packages/core/src/api/parsers/html/officeHTML.browser.test.ts b/packages/core/src/api/parsers/html/officeHTML.browser.test.ts index 242ff8d408..c74e2d300d 100644 --- a/packages/core/src/api/parsers/html/officeHTML.browser.test.ts +++ b/packages/core/src/api/parsers/html/officeHTML.browser.test.ts @@ -28,6 +28,25 @@ 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 HTML parsing", () => { it("reconstructs Word lists, retaining formatting and nesting", () => { const blocks = createEditor().tryParseHTMLToBlocks( @@ -90,6 +109,72 @@ describe("Office HTML parsing", () => { expect(blocks).toHaveLength(2); }); + it("preserves the start of a numbered item that has nested children", () => { + const blocks = createEditor().tryParseHTMLToBlocks( + wordItem("Parent", 1, "4.") + + wordItem("Child", 2, "3.") + + wordItem("Next", 1, "5."), + ); + expect(blocks).toMatchObject([ + { + type: "numberedListItem", + props: { start: 4 }, + children: [{ type: "numberedListItem", props: { start: 3 } }], + }, + { type: "numberedListItem" }, + ]); + expect(blocks[1].props).not.toHaveProperty("start"); + }); + + it("preserves numbering gaps and restarts through HTML round trips", () => { + const editor = createEditor(); + const blocks = editor.tryParseHTMLToBlocks( + wordItem("First", 1, "4.") + + wordItem("Gap", 1, "7.") + + wordItem("Next", 1, "8.") + + wordItem("Restart", 1, "1.", "l1") + + wordItem("Last", 1, "2.", "l1"), + ); + expect( + blocks.map((block) => + block.type === "numberedListItem" ? block.props.start : undefined, + ), + ).toEqual([4, 7, undefined, 1, undefined]); + for (const html of [ + editor.blocksToHTMLLossy(blocks), + editor.blocksToFullHTML(blocks), + ]) { + expect( + editor.tryParseHTMLToBlocks(html).map((block) => block.props), + ).toEqual(blocks.map((block) => block.props)); + } + const root = document.createElement("div"); + root.innerHTML = editor.blocksToFullHTML(blocks); + expect( + Array.from( + root.querySelectorAll('[data-content-type="numberedListItem"]'), + (item) => item.getAttribute("data-index"), + ), + ).toEqual(["4", "7", "8", "1", "2"]); + }); + + it("recognizes parenthesized numbers and retains unsupported Word labels", () => { + const blocks = createEditor().tryParseHTMLToBlocks( + wordItem("Number", 1, "(3)") + + wordItem("Multilevel", 1, "1.2.") + + wordItem("Unknown", 1, "Section A:"), + ); + expect(blocks).toMatchObject([ + { + type: "numberedListItem", + props: { start: 3 }, + content: [{ text: "Number" }], + }, + { type: "paragraph", content: [{ text: "1.2.\u00a0Multilevel" }] }, + { type: "paragraph", content: [{ text: "Section A:\u00a0Unknown" }] }, + ]); + }); + it("preserves Google Docs blank paragraphs but leaves inline breaks alone", () => { const blocks = createEditor().tryParseHTMLToBlocks( '

First
line



Last

', @@ -112,6 +197,29 @@ describe("Office HTML parsing", () => { ]); }); + it.each([ + ["First
Last", "First\nLast"], + ["First
Last", "First\nLast"], + ["First \n
\n Last", "First \nLast"], + ])( + "keeps hard breaks in Google Docs inline fragments: %s", + (fragment, text) => { + const blocks = createEditor().tryParseHTMLToBlocks( + `${fragment}`, + ); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "paragraph", + content: [{ text }], + }); + }, + ); + + it("leaves unrelated HTML unchanged", () => { + const html = "

Original


Last

"; + expect(defaultHTMLPreprocessor(html)).toBe(html); + }); + it("can replace the built-in normalization", () => { const preprocessHTML = vi.fn(() => "

Replacement

"); const html = wordItem("Original"); @@ -147,15 +255,8 @@ describe("Office HTML parsing", () => { containers.push(container); editor.mount(container); const html = wordItem("Original"); - const clipboardData = new DataTransfer(); - clipboardData.setData("text/html", html); - clipboardData.setData("text/plain", "Original"); editor.prosemirrorView.dom.dispatchEvent( - new ClipboardEvent("paste", { - clipboardData, - bubbles: true, - cancelable: true, - }), + createPasteEvent({ "text/html": html, "text/plain": "Original" }), ); expect(preprocessHTML).toHaveBeenCalledExactlyOnceWith(html); expect(editor.document[0]).toMatchObject({ @@ -177,4 +278,25 @@ describe("Office HTML parsing", () => { content: [{ text: "Original" }], }); }); + + it("does not preprocess internal BlockNote clipboard HTML", () => { + const preprocessHTML = vi.fn(() => "

Changed

"); + const editor = createEditor({ preprocessHTML }); + const container = document.createElement("div"); + document.body.append(container); + containers.push(container); + editor.mount(container); + editor.prosemirrorView.dom.dispatchEvent( + createPasteEvent({ + "blocknote/html": editor.blocksToFullHTML([ + { type: "paragraph", content: "Original" }, + ]), + "text/html": "

Fallback

", + }), + ); + expect(preprocessHTML).not.toHaveBeenCalled(); + expect(editor.document[0]).toMatchObject({ + content: [{ text: "Original" }], + }); + }); }); diff --git a/packages/core/src/api/parsers/html/preprocessHTML.ts b/packages/core/src/api/parsers/html/preprocessHTML.ts index ae8cc77f92..373fdfdbeb 100644 --- a/packages/core/src/api/parsers/html/preprocessHTML.ts +++ b/packages/core/src/api/parsers/html/preprocessHTML.ts @@ -21,6 +21,7 @@ function normalizeWordLists(root: HTMLElement) { element: HTMLElement; level: number; lastItem: HTMLLIElement; + nextNumber: number | undefined; }[] = []; let listID: string | undefined; @@ -42,21 +43,43 @@ function normalizeWordLists(root: HTMLElement) { listID = id; let marker = ""; + const markers: Element[] = []; for (const span of Array.from(paragraph.querySelectorAll("span[style]"))) { - if (getMSOList(span)?.toLowerCase() === "ignore") { + if ( + getMSOList(span)?.toLowerCase() === "ignore" && + !markers.some((parent) => parent.contains(span)) + ) { marker += span.textContent; - span.remove(); + markers.push(span); } } marker = marker.trim(); - const tag = /^(?:\d+|[a-z]+)[.)]$/i.test(marker) ? "ol" : "ul"; + const numericMarker = marker.match(/^(?:([0-9]+)[.)]|\(([0-9]+)\))$/); + const start = numericMarker + ? Number(numericMarker[1] ?? numericMarker[2]) + : undefined; + const ordered = numericMarker || /^[a-z]+[.)]$/i.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▪●○■□‣⁃\-\uF000-\uF8FF]$/u.test(marker)) || + (start !== undefined && !Number.isSafeInteger(start)) + ) { + 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[stack.length - 1].element.tagName.toLowerCase() !== tag || + (start !== undefined && stack[stack.length - 1].nextNumber !== start)) ) { stack.pop(); } @@ -71,8 +94,7 @@ function normalizeWordLists(root: HTMLElement) { let current = stack[stack.length - 1]; if (!current || current.level !== level) { const element = root.ownerDocument.createElement(tag); - const start = Number.parseInt(marker, 10); - if (tag === "ol" && start > 1) { + if (start !== undefined) { element.setAttribute("start", String(start)); } if (current) { @@ -80,16 +102,32 @@ function normalizeWordLists(root: HTMLElement) { } else { paragraph.before(element); } - current = { element, level, lastItem: item }; + current = { element, level, lastItem: item, nextNumber: undefined }; stack.push(current); } current.element.append(item); current.lastItem = item; + current.nextNumber = start === undefined ? undefined : start + 1; item.append(paragraph); } } +// Google Docs uses direct BR children for empty blocks, but copied inline +// fragments use those same BR elements for hard breaks. Only replace breaks +// bounded by blocks, other breaks, or the edge of the wrapper. +function isBlockBoundary(node: ChildNode | null): boolean { + if (!node) { + return true; + } + return ( + node.nodeType === 1 && + /^(BR|P|DIV|H[1-6]|UL|OL|TABLE|BLOCKQUOTE|PRE|HR|FIGURE)$/.test( + node.nodeName, + ) + ); +} + /** * Normalizes Microsoft Word lists and paragraph ends, and Google Docs blank * paragraphs. Other HTML is left unchanged. This is not an HTML sanitizer. @@ -126,7 +164,27 @@ export function defaultHTMLPreprocessor(html: string): string { for (const wrapper of root.querySelectorAll(googleDocsWrapper)) { for (const child of Array.from(wrapper.children)) { - if (child.tagName === "BR") { + let before = child.previousSibling; + let after = child.nextSibling; + while ( + before && + (before.nodeType === 8 || + (before.nodeType === 3 && !before.textContent?.trim())) + ) { + before = before.previousSibling; + } + while ( + after && + (after.nodeType === 8 || + (after.nodeType === 3 && !after.textContent?.trim())) + ) { + after = after.nextSibling; + } + if ( + child.tagName === "BR" && + isBlockBoundary(before) && + isBlockBoundary(after) + ) { const paragraph = document.createElement("p"); // The schema drops empty paragraphs; use the same temporary placeholder // as the HTML exporter. HTMLToBlocks removes it after parsing. diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts new file mode 100644 index 0000000000..f3904fec44 --- /dev/null +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { EditorState } from "@tiptap/pm/state"; +import { afterEach, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { splitBlockTr } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; +import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; + +const editors: BlockNoteEditor[] = []; + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } +}); + +function createState(starts: (number | undefined)[]) { + const editor = BlockNoteEditor.create(); + editors.push(editor); + const schema = editor.pmSchema; + const plugin = NumberedListIndexingDecorationPlugin(); + const doc = schema.nodes.doc.create( + null, + schema.nodes.blockGroup.create( + null, + starts.map((start, index) => + schema.nodes.blockContainer.create( + { id: String(index) }, + schema.nodes.numberedListItem.create({ start }, schema.text("Item")), + ), + ), + ), + ); + const state = EditorState.create({ schema, doc, plugins: [plugin] }); + return { state, plugin }; +} + +it("honors explicit starts and restarts in consecutive numbered blocks", () => { + const { state, plugin } = createState([ + 4, + undefined, + 1, + undefined, + 7, + undefined, + 0, + ]); + expect( + plugin + .getState(state)! + .decorations.find() + .map((decoration) => decoration.spec.index), + ).toEqual([4, 5, 1, 2, 7, 8, 0]); +}); + +it("finds an earlier restart when updating a later item without a cached predecessor", () => { + const { state, plugin } = createState([ + 4, + undefined, + 1, + undefined, + undefined, + ]); + let lastPosition = 0; + state.doc.descendants((node, position) => { + if (node.type.name === "numberedListItem") { + lastPosition = position; + } + }); + const next = state.apply(state.tr.insertText("x", lastPosition + 1)); + expect( + plugin + .getState(next)! + .decorations.find() + .map((decoration) => decoration.spec.index), + ).toEqual([4, 5, 1, 2, 3]); +}); + +it("renumbers following items when an explicit restart changes", () => { + const { state, plugin } = createState([4, undefined, 1, undefined]); + let restartPosition = 0; + state.doc.descendants((node, position) => { + if (node.type.name === "numberedListItem" && node.attrs.start === 1) { + restartPosition = position; + } + }); + const next = state.apply( + state.tr.setNodeAttribute(restartPosition, "start", 8), + ); + expect( + plugin + .getState(next)! + .decorations.find() + .map((decoration) => decoration.spec.index), + ).toEqual([4, 5, 8, 9]); +}); + +it("does not duplicate a numbering restart when splitting with preserved props", () => { + const { state, plugin } = createState([4, undefined]); + const tr = state.tr; + expect(splitBlockTr(tr, 3, true, true)).toBe(true); + const next = state.apply(tr); + expect( + plugin + .getState(next)! + .decorations.find() + .map((decoration) => decoration.spec.index), + ).toEqual([4, 5, 6]); +}); diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index 0222fff46a..dc83a6bb66 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -28,7 +28,12 @@ function calculateListItemIndex( tr: Transaction, map: Map, ): { index: number; isFirst: boolean; hasStart: boolean } { - const hasStart = !!node.firstChild!.attrs["start"]; + const start = node.firstChild!.attrs["start"]; + const hasStart = typeof start === "number" && Number.isFinite(start); + if (hasStart) { + map.set(node, start); + return { index: start, isFirst: true, hasStart }; + } // Fast path: previous sibling already in cache const blockInfo = getBlockInfoFromNode(node, pos); @@ -64,6 +69,12 @@ function calculateListItemIndex( break; } chain.push({ node: curNode, pos: curBeforePos - curNode.nodeSize }); + // A restart is the beginning of this numbering sequence, even when it + // follows another numbered item and has not been visited in this traversal. + const curStart = curNode.firstChild!.attrs["start"]; + if (typeof curStart === "number" && Number.isFinite(curStart)) { + break; + } const nextPrev = tr.doc.resolve(curInfo.block.beforePos).nodeBefore; curBeforePos = curInfo.block.beforePos; curNode = nextPrev; @@ -85,12 +96,13 @@ function calculateListItemIndex( ? map.get(predecessorNode) : undefined; - if (predecessorIndex !== undefined) { + const chainStart = lastInChain.node.firstChild!.attrs["start"]; + if (predecessorIndex !== undefined && chainStart === undefined) { index = predecessorIndex; isFirst = false; } else { // Start of a new list - index = (lastInChain.node.firstChild!.attrs["start"] || 1) - 1; + index = (chainStart ?? 1) - 1; isFirst = true; } diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 57530d741b..141bb107f6 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -52,11 +52,18 @@ export const createNumberedListItemBlockSpec = createBlockSpec( parent.tagName === "OL" || (parent.tagName === "DIV" && parent.parentElement?.tagName === "OL") ) { - const startIndex = parseInt(parent.getAttribute("start") || "1"); + const list = parent.tagName === "OL" ? parent : parent.parentElement!; + const firstItem = parent === list ? element : parent; + const start = list.getAttribute("start"); + const startIndex = Number(start); const defaultProps = parseDefaultProps(element); - if (element.previousElementSibling || startIndex === 1) { + if ( + firstItem.previousElementSibling || + start === null || + !Number.isSafeInteger(startIndex) + ) { return defaultProps; } From f25e3260720794c3de289469a7c0473f9991bf4a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 22 Oct 2026 05:03:30 +0200 Subject: [PATCH 3/4] refactor(core): simplify Google Docs break normalization --- .../parsers/html/officeHTML.browser.test.ts | 2 +- .../src/api/parsers/html/preprocessHTML.ts | 31 ++++++------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/core/src/api/parsers/html/officeHTML.browser.test.ts b/packages/core/src/api/parsers/html/officeHTML.browser.test.ts index c74e2d300d..7f8286a7e5 100644 --- a/packages/core/src/api/parsers/html/officeHTML.browser.test.ts +++ b/packages/core/src/api/parsers/html/officeHTML.browser.test.ts @@ -177,7 +177,7 @@ describe("Office HTML parsing", () => { it("preserves Google Docs blank paragraphs but leaves inline breaks alone", () => { const blocks = createEditor().tryParseHTMLToBlocks( - '

First
line



Last

', + '

First
line

\n
\n
\n

Last

', ); expect(blocks).toMatchObject([ { type: "paragraph", content: [{ text: "First\nline", styles: {} }] }, diff --git a/packages/core/src/api/parsers/html/preprocessHTML.ts b/packages/core/src/api/parsers/html/preprocessHTML.ts index 373fdfdbeb..7f8e5aa2cc 100644 --- a/packages/core/src/api/parsers/html/preprocessHTML.ts +++ b/packages/core/src/api/parsers/html/preprocessHTML.ts @@ -116,7 +116,7 @@ function normalizeWordLists(root: HTMLElement) { // Google Docs uses direct BR children for empty blocks, but copied inline // fragments use those same BR elements for hard breaks. Only replace breaks // bounded by blocks, other breaks, or the edge of the wrapper. -function isBlockBoundary(node: ChildNode | null): boolean { +function isBlockBoundary(node: ChildNode | undefined): boolean { if (!node) { return true; } @@ -163,27 +163,16 @@ export function defaultHTMLPreprocessor(html: string): string { } for (const wrapper of root.querySelectorAll(googleDocsWrapper)) { - for (const child of Array.from(wrapper.children)) { - let before = child.previousSibling; - let after = child.nextSibling; - while ( - before && - (before.nodeType === 8 || - (before.nodeType === 3 && !before.textContent?.trim())) - ) { - before = before.previousSibling; - } - while ( - after && - (after.nodeType === 8 || - (after.nodeType === 3 && !after.textContent?.trim())) - ) { - after = after.nextSibling; - } + const children = Array.from(wrapper.childNodes).filter( + (node) => + node.nodeType !== 8 && + (node.nodeType !== 3 || !!node.textContent?.trim()), + ); + for (const [index, child] of children.entries()) { if ( - child.tagName === "BR" && - isBlockBoundary(before) && - isBlockBoundary(after) + child.nodeName === "BR" && + isBlockBoundary(children[index - 1]) && + isBlockBoundary(children[index + 1]) ) { const paragraph = document.createElement("p"); // The schema drops empty paragraphs; use the same temporary placeholder From b11d38f98cb1ef5ca9c0feac679de93669626aef Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 23 Oct 2026 17:36:29 +0200 Subject: [PATCH 4/4] refactor(core): limit Office normalization to clipboard pastes --- .../docs/reference/editor/paste-handling.mdx | 30 +-- .../commands/splitBlock/splitBlock.ts | 11 +- .../fromClipboard/normalizeOfficeHTML.ts} | 93 +++----- .../fromClipboard}/officeHTML.browser.test.ts | 217 +++++++++--------- .../clipboard/fromClipboard/pasteExtension.ts | 3 +- .../exporters/html/internalHTMLSerializer.ts | 8 +- .../html/util/serializeBlocksExternalHTML.ts | 17 +- .../core/src/api/parsers/html/parseHTML.ts | 11 +- .../IndexingPlugin.node.test.ts | 109 --------- .../NumberedListItem/IndexingPlugin.ts | 18 +- .../blocks/ListItem/NumberedListItem/block.ts | 11 +- packages/core/src/editor/BlockNoteEditor.ts | 17 +- .../core/src/editor/managers/ExportManager.ts | 8 +- packages/core/src/index.ts | 1 - .../parse/__snapshots__/html/googleDocs.json | 9 +- .../parse/__snapshots__/html/msWordPaste.json | 8 +- 16 files changed, 186 insertions(+), 385 deletions(-) rename packages/core/src/api/{parsers/html/preprocessHTML.ts => clipboard/fromClipboard/normalizeOfficeHTML.ts} (59%) rename packages/core/src/api/{parsers/html => clipboard/fromClipboard}/officeHTML.browser.test.ts (55%) delete mode 100644 packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts diff --git a/docs/content/docs/reference/editor/paste-handling.mdx b/docs/content/docs/reference/editor/paste-handling.mdx index 279956d0d4..1824a17e74 100644 --- a/docs/content/docs/reference/editor/paste-handling.mdx +++ b/docs/content/docs/reference/editor/paste-handling.mdx @@ -25,31 +25,15 @@ You can change the default paste behavior by providing a custom paste handler, w BlockNote automatically converts Microsoft Word list paragraphs into nested lists, removes Word paragraph-end placeholders, and preserves blank paragraphs copied from -Google Docs. This also applies when importing HTML with `tryParseHTMLToBlocks` or -inserting it with `pasteHTML`. +Google Docs. This applies to external HTML pasted from the clipboard. -Word list numbering retains numeric starts, gaps, and restarts. Labels that -BlockNote cannot represent, such as `1.2.`, stay as text instead of being removed. +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. -### Custom HTML preprocessing - -Use the `preprocessHTML` editor option to replace the source normalization. The -function receives the original HTML string and returns the HTML to parse. To extend -the built-in handling, call `defaultHTMLPreprocessor` inside your function: - -```ts -import { BlockNoteEditor, defaultHTMLPreprocessor } from "@blocknote/core"; - -const editor = BlockNoteEditor.create({ - preprocessHTML(html) { - return defaultHTMLPreprocessor(html).replaceAll("Old label", "New label"); - }, -}); -``` - -Set `preprocessHTML: false` to disable Word and Google Docs normalization. Neither -the default nor custom preprocessing runs for internal BlockNote clipboard HTML or -`pasteHTML(html, true)`. Preprocessing is not an HTML sanitizer. +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 diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 7f399fee7e..96b82dbd22 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -59,16 +59,7 @@ export const splitBlockTr = ( }, { type: keepType ? info.content.node.type : schema.nodes["paragraph"], - attrs: keepProps - ? { - ...info.content.node.attrs, - // The second half continues the sequence, rather than copying a - // numbering restart from the original item. - ...(info.blockNoteType === "numberedListItem" && { - start: undefined, - }), - } - : {}, + attrs: keepProps ? { ...info.content.node.attrs } : {}, }, ]; diff --git a/packages/core/src/api/parsers/html/preprocessHTML.ts b/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts similarity index 59% rename from packages/core/src/api/parsers/html/preprocessHTML.ts rename to packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts index 7f8e5aa2cc..4bd2ad43d4 100644 --- a/packages/core/src/api/parsers/html/preprocessHTML.ts +++ b/packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts @@ -1,8 +1,5 @@ import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js"; -/** Transforms source HTML before BlockNote parses its blocks and inline content. */ -export type HTMLPreprocessor = (html: string) => string; - // 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; @@ -21,7 +18,6 @@ function normalizeWordLists(root: HTMLElement) { element: HTMLElement; level: number; lastItem: HTMLLIElement; - nextNumber: number | undefined; }[] = []; let listID: string | undefined; @@ -34,10 +30,15 @@ function normalizeWordLists(root: HTMLElement) { const level = Number(match[2]); const id = `${match[1]}:${match[3]}`; - if ( - id !== listID || - paragraph.previousElementSibling !== stack[0]?.element + 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; @@ -54,17 +55,10 @@ function normalizeWordLists(root: HTMLElement) { } } marker = marker.trim(); - const numericMarker = marker.match(/^(?:([0-9]+)[.)]|\(([0-9]+)\))$/); - const start = numericMarker - ? Number(numericMarker[1] ?? numericMarker[2]) - : undefined; - const ordered = numericMarker || /^[a-z]+[.)]$/i.test(marker); + 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▪●○■□‣⁃\-\uF000-\uF8FF]$/u.test(marker)) || - (start !== undefined && !Number.isSafeInteger(start)) - ) { + if (!ordered && marker && !/^[·•o▪●○■□‣⁃\-\uF0B7]$/u.test(marker)) { stack.length = 0; continue; } @@ -78,8 +72,7 @@ function normalizeWordLists(root: HTMLElement) { } if ( stack[stack.length - 1]?.level === level && - (stack[stack.length - 1].element.tagName.toLowerCase() !== tag || - (start !== undefined && stack[stack.length - 1].nextNumber !== start)) + stack[stack.length - 1].element.tagName.toLowerCase() !== tag ) { stack.pop(); } @@ -87,54 +80,30 @@ function normalizeWordLists(root: HTMLElement) { const item = root.ownerDocument.createElement("li"); // Block properties are parsed from the li, while the paragraph below keeps // its styles for inline-content parsing. - const style = paragraph.getAttribute("style"); - if (style) { - item.setAttribute("style", style); - } + item.setAttribute("style", paragraph.getAttribute("style")!); let current = stack[stack.length - 1]; if (!current || current.level !== level) { const element = root.ownerDocument.createElement(tag); - if (start !== undefined) { - element.setAttribute("start", String(start)); - } if (current) { current.lastItem.append(element); } else { paragraph.before(element); } - current = { element, level, lastItem: item, nextNumber: undefined }; + current = { element, level, lastItem: item }; stack.push(current); } current.element.append(item); current.lastItem = item; - current.nextNumber = start === undefined ? undefined : start + 1; item.append(paragraph); } } -// Google Docs uses direct BR children for empty blocks, but copied inline -// fragments use those same BR elements for hard breaks. Only replace breaks -// bounded by blocks, other breaks, or the edge of the wrapper. -function isBlockBoundary(node: ChildNode | undefined): boolean { - if (!node) { - return true; - } - return ( - node.nodeType === 1 && - /^(BR|P|DIV|H[1-6]|UL|OL|TABLE|BLOCKQUOTE|PRE|HR|FIGURE)$/.test( - node.nodeName, - ) - ); -} - /** * Normalizes Microsoft Word lists and paragraph ends, and Google Docs blank * paragraphs. Other HTML is left unchanged. This is not an HTML sanitizer. - * - * Call this from a custom `preprocessHTML` callback to extend the built-in behavior. */ -export function defaultHTMLPreprocessor(html: string): string { +export function normalizeOfficeHTML(html: string): string { const isWord = wordHTML.test(html); if (!isWord && !html.includes("docs-internal-guid-")) { return html; @@ -163,23 +132,23 @@ export function defaultHTMLPreprocessor(html: string): string { } for (const wrapper of root.querySelectorAll(googleDocsWrapper)) { - const children = Array.from(wrapper.childNodes).filter( - (node) => - node.nodeType !== 8 && - (node.nodeType !== 3 || !!node.textContent?.trim()), - ); - for (const [index, child] of children.entries()) { - if ( - child.nodeName === "BR" && - isBlockBoundary(children[index - 1]) && - isBlockBoundary(children[index + 1]) - ) { - const paragraph = document.createElement("p"); - // The schema drops empty paragraphs; use the same temporary placeholder - // as the HTML exporter. HTMLToBlocks removes it after parsing. - paragraph.textContent = EMPTY_BLOCK_PLACEHOLDER; - child.replaceWith(paragraph); - } + // 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/parsers/html/officeHTML.browser.test.ts b/packages/core/src/api/clipboard/fromClipboard/officeHTML.browser.test.ts similarity index 55% rename from packages/core/src/api/parsers/html/officeHTML.browser.test.ts rename to packages/core/src/api/clipboard/fromClipboard/officeHTML.browser.test.ts index 7f8286a7e5..3af0fd73da 100644 --- a/packages/core/src/api/parsers/html/officeHTML.browser.test.ts +++ b/packages/core/src/api/clipboard/fromClipboard/officeHTML.browser.test.ts @@ -1,7 +1,7 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; -import { defaultHTMLPreprocessor } from "./preprocessHTML.js"; +import { normalizeOfficeHTML } from "./normalizeOfficeHTML.js"; // These tests exercise browser HTML parsing and native clipboard events. const editors: BlockNoteEditor[] = []; @@ -15,6 +15,21 @@ function createEditor( 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(); @@ -47,14 +62,14 @@ function createPasteEvent(data: Record) { return event; } -describe("Office HTML parsing", () => { +describe("Office clipboard HTML", () => { it("reconstructs Word lists, retaining formatting and nesting", () => { - const blocks = createEditor().tryParseHTMLToBlocks( + const blocks = parseOfficeHTML( wordItem("First").replace( 'style="mso-list:', 'style="text-align:center;mso-list:', ) + - wordItem("Nested", 2, "3.") + + wordItem("Nested", 2, "1.") + wordItem("Second"), ); expect(blocks).toMatchObject([ @@ -65,7 +80,6 @@ describe("Office HTML parsing", () => { children: [ { type: "numberedListItem", - props: { start: 3 }, content: [{ text: "Nested" }], }, ], @@ -76,13 +90,13 @@ describe("Office HTML parsing", () => { }); it("separates Word lists across paragraphs, list IDs and marker types", () => { - const blocks = createEditor().tryParseHTMLToBlocks( + const blocks = parseOfficeHTML( wordItem("One", 1, "4.") + "

Between

" + wordItem("Two", 1, "7.") + wordItem("Bullet") + wordItem("New list", 1, "2.", "l1") + - wordItem("Another list", 1, "8.", "l2"), + wordItem("Another list", 2, "1.", "l2"), ); expect(blocks.map((block) => block.type)).toEqual([ "numberedListItem", @@ -92,14 +106,12 @@ describe("Office HTML parsing", () => { "numberedListItem", "numberedListItem", ]); - expect(blocks[0].props).toMatchObject({ start: 4 }); - expect(blocks[2].props).toMatchObject({ start: 7 }); - expect(blocks[4].props).toMatchObject({ start: 2 }); - expect(blocks[5].props).toMatchObject({ start: 8 }); + expect(blocks).toHaveLength(6); + expect(blocks[4].children).toEqual([]); }); it("removes Word paragraph-end placeholders without removing empty paragraphs", () => { - const blocks = createEditor().tryParseHTMLToBlocks( + const blocks = parseOfficeHTML( '

Text 

 

', ); expect(blocks).toMatchObject([ @@ -109,74 +121,57 @@ describe("Office HTML parsing", () => { expect(blocks).toHaveLength(2); }); - it("preserves the start of a numbered item that has nested children", () => { - const blocks = createEditor().tryParseHTMLToBlocks( - wordItem("Parent", 1, "4.") + - wordItem("Child", 2, "3.") + - wordItem("Next", 1, "5."), + 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", - props: { start: 4 }, - children: [{ type: "numberedListItem", props: { start: 3 } }], + content: [{ text: "Parent" }], + children: [{ type: "numberedListItem", content: [{ text: "Child" }] }], }, - { type: "numberedListItem" }, + { type: "numberedListItem", content: [{ text: "Next" }] }, ]); - expect(blocks[1].props).not.toHaveProperty("start"); - }); - - it("preserves numbering gaps and restarts through HTML round trips", () => { - const editor = createEditor(); - const blocks = editor.tryParseHTMLToBlocks( - wordItem("First", 1, "4.") + - wordItem("Gap", 1, "7.") + - wordItem("Next", 1, "8.") + - wordItem("Restart", 1, "1.", "l1") + - wordItem("Last", 1, "2.", "l1"), - ); - expect( - blocks.map((block) => - block.type === "numberedListItem" ? block.props.start : undefined, - ), - ).toEqual([4, 7, undefined, 1, undefined]); - for (const html of [ - editor.blocksToHTMLLossy(blocks), - editor.blocksToFullHTML(blocks), - ]) { - expect( - editor.tryParseHTMLToBlocks(html).map((block) => block.props), - ).toEqual(blocks.map((block) => block.props)); - } - const root = document.createElement("div"); - root.innerHTML = editor.blocksToFullHTML(blocks); - expect( - Array.from( - root.querySelectorAll('[data-content-type="numberedListItem"]'), - (item) => item.getAttribute("data-index"), - ), - ).toEqual(["4", "7", "8", "1", "2"]); + expect(blocks).toHaveLength(2); }); it("recognizes parenthesized numbers and retains unsupported Word labels", () => { - const blocks = createEditor().tryParseHTMLToBlocks( + const blocks = parseOfficeHTML( wordItem("Number", 1, "(3)") + wordItem("Multilevel", 1, "1.2.") + - wordItem("Unknown", 1, "Section A:"), + wordItem("Unknown", 1, "Section A:") + + wordItem("Roman", 1, "iv.") + + wordItem("Letter", 1, "c."), ); expect(blocks).toMatchObject([ { type: "numberedListItem", - props: { start: 3 }, 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 = createEditor().tryParseHTMLToBlocks( + const blocks = parseOfficeHTML( '

First
line

\n
\n
\n

Last

', ); expect(blocks).toMatchObject([ @@ -188,10 +183,8 @@ describe("Office HTML parsing", () => { expect(blocks).toHaveLength(4); }); - it("can disable the built-in normalization", () => { - const blocks = createEditor({ preprocessHTML: false }).tryParseHTMLToBlocks( - wordItem("Unchanged"), - ); + it("leaves programmatic HTML imports unchanged", () => { + const blocks = createEditor().tryParseHTMLToBlocks(wordItem("Unchanged")); expect(blocks).toMatchObject([ { type: "paragraph", content: [{ text: "·\u00a0Unchanged" }] }, ]); @@ -199,12 +192,13 @@ describe("Office HTML parsing", () => { 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 = createEditor().tryParseHTMLToBlocks( + const blocks = parseOfficeHTML( `${fragment}`, ); expect(blocks).toHaveLength(1); @@ -217,75 +211,77 @@ describe("Office HTML parsing", () => { it("leaves unrelated HTML unchanged", () => { const html = "

Original


Last

"; - expect(defaultHTMLPreprocessor(html)).toBe(html); + expect(normalizeOfficeHTML(html)).toBe(html); }); - it("can replace the built-in normalization", () => { - const preprocessHTML = vi.fn(() => "

Replacement

"); - const html = wordItem("Original"); - const blocks = createEditor({ preprocessHTML }).tryParseHTMLToBlocks(html); - expect(preprocessHTML).toHaveBeenCalledExactlyOnceWith(html); - expect(blocks).toMatchObject([ - { - type: "heading", - props: { level: 2 }, - content: [{ text: "Replacement" }], + 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("can extend the built-in normalization", () => { - const editor = createEditor({ - preprocessHTML(html) { - return defaultHTMLPreprocessor(html).replace("Original", "Changed"); - }, + 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" }], }); - expect(editor.tryParseHTMLToBlocks(wordItem("Original"))).toMatchObject([ - { type: "bulletListItem", content: [{ text: "Changed" }] }, - ]); }); - it("uses the same preprocessing for clipboard HTML", () => { - const preprocessHTML = vi.fn((html: string) => - defaultHTMLPreprocessor(html).replace("Original", "Changed"), - ); - const editor = createEditor({ preprocessHTML }); - const container = document.createElement("div"); - document.body.append(container); - containers.push(container); - editor.mount(container); + 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(preprocessHTML).toHaveBeenCalledExactlyOnceWith(html); expect(editor.document[0]).toMatchObject({ type: "bulletListItem", - content: [{ text: "Changed" }], + content: [{ text: "Original" }], }); }); - it("does not preprocess raw HTML pastes", () => { - const preprocessHTML = vi.fn(() => "

Changed

"); - const editor = createEditor({ preprocessHTML }); - const container = document.createElement("div"); - document.body.append(container); - containers.push(container); - editor.mount(container); - editor.pasteHTML("

Original

", true); - expect(preprocessHTML).not.toHaveBeenCalled(); + 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({ - content: [{ text: "Original" }], + type: "paragraph", + content: [{ text: "·\u00a0Original" }], }); }); it("does not preprocess internal BlockNote clipboard HTML", () => { - const preprocessHTML = vi.fn(() => "

Changed

"); - const editor = createEditor({ preprocessHTML }); - const container = document.createElement("div"); - document.body.append(container); - containers.push(container); - editor.mount(container); + const editor = mountEditor(); editor.prosemirrorView.dom.dispatchEvent( createPasteEvent({ "blocknote/html": editor.blocksToFullHTML([ @@ -294,7 +290,6 @@ describe("Office HTML parsing", () => { "text/html": "

Fallback

", }), ); - expect(preprocessHTML).not.toHaveBeenCalled(); 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; } diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 598b3be3ef..05338f5f97 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -24,9 +24,11 @@ const addIndexToNumberedListItems = (element: HTMLElement) => { '[data-content-type="numberedListItem"]', ); - const start = numberedListItem.getAttribute("data-start"); - if (start !== null || !prevNumberedListItem) { - numberedListItem.setAttribute("data-index", start ?? "1"); + if (!prevNumberedListItem) { + numberedListItem.setAttribute( + "data-index", + numberedListItem.getAttribute("data-start") || "1", + ); } else { const prevNumberedListItemIndex = prevNumberedListItem.getAttribute("data-index"); diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 44ad3fefd2..0a267d1b43 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -391,16 +391,17 @@ function serializeBlock< } if (listType) { - const start = "start" in props ? props.start : undefined; - const hasStart = typeof start === "number" && Number.isFinite(start); - if ( - fragment.lastChild?.nodeName !== listType || - (listType === "OL" && hasStart) - ) { + if (fragment.lastChild?.nodeName !== listType) { const list = doc.createElement(listType); - if (listType === "OL" && hasStart) { - list.setAttribute("start", String(start)); + if ( + listType === "OL" && + "start" in props && + props.start && + props?.start !== 1 + ) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + list.setAttribute("start", String(props.start)); } fragment.append(list); } diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts index 15f036ba4d..86243d5be9 100644 --- a/packages/core/src/api/parsers/html/parseHTML.ts +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -8,7 +8,6 @@ import { import { Block } from "../../../blocks/defaultBlocks.js"; import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; -import { defaultHTMLPreprocessor, HTMLPreprocessor } from "./preprocessHTML.js"; import { nestedListsToBlockNoteStructure } from "./util/nestedLists.js"; import { preprocessHTMLWhitespace } from "./util/normalizeWhitespace.js"; @@ -56,14 +55,8 @@ export function HTMLToBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, ->( - html: string, - pmSchema: Schema, - preprocessHTML: HTMLPreprocessor | false = defaultHTMLPreprocessor, -): Block[] { - const htmlNode = nestedListsToBlockNoteStructure( - preprocessHTML === false ? html : preprocessHTML(html), - ); +>(html: string, pmSchema: Schema): Block[] { + const htmlNode = nestedListsToBlockNoteStructure(html); preprocessHTMLWhitespace(htmlNode); const parser = DOMParser.fromSchema(pmSchema); diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts deleted file mode 100644 index f3904fec44..0000000000 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.node.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment node -import { EditorState } from "@tiptap/pm/state"; -import { afterEach, expect, it } from "vite-plus/test"; - -import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; -import { splitBlockTr } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; -import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; - -const editors: BlockNoteEditor[] = []; - -afterEach(() => { - for (const editor of editors.splice(0)) { - editor._tiptapEditor.destroy(); - } -}); - -function createState(starts: (number | undefined)[]) { - const editor = BlockNoteEditor.create(); - editors.push(editor); - const schema = editor.pmSchema; - const plugin = NumberedListIndexingDecorationPlugin(); - const doc = schema.nodes.doc.create( - null, - schema.nodes.blockGroup.create( - null, - starts.map((start, index) => - schema.nodes.blockContainer.create( - { id: String(index) }, - schema.nodes.numberedListItem.create({ start }, schema.text("Item")), - ), - ), - ), - ); - const state = EditorState.create({ schema, doc, plugins: [plugin] }); - return { state, plugin }; -} - -it("honors explicit starts and restarts in consecutive numbered blocks", () => { - const { state, plugin } = createState([ - 4, - undefined, - 1, - undefined, - 7, - undefined, - 0, - ]); - expect( - plugin - .getState(state)! - .decorations.find() - .map((decoration) => decoration.spec.index), - ).toEqual([4, 5, 1, 2, 7, 8, 0]); -}); - -it("finds an earlier restart when updating a later item without a cached predecessor", () => { - const { state, plugin } = createState([ - 4, - undefined, - 1, - undefined, - undefined, - ]); - let lastPosition = 0; - state.doc.descendants((node, position) => { - if (node.type.name === "numberedListItem") { - lastPosition = position; - } - }); - const next = state.apply(state.tr.insertText("x", lastPosition + 1)); - expect( - plugin - .getState(next)! - .decorations.find() - .map((decoration) => decoration.spec.index), - ).toEqual([4, 5, 1, 2, 3]); -}); - -it("renumbers following items when an explicit restart changes", () => { - const { state, plugin } = createState([4, undefined, 1, undefined]); - let restartPosition = 0; - state.doc.descendants((node, position) => { - if (node.type.name === "numberedListItem" && node.attrs.start === 1) { - restartPosition = position; - } - }); - const next = state.apply( - state.tr.setNodeAttribute(restartPosition, "start", 8), - ); - expect( - plugin - .getState(next)! - .decorations.find() - .map((decoration) => decoration.spec.index), - ).toEqual([4, 5, 8, 9]); -}); - -it("does not duplicate a numbering restart when splitting with preserved props", () => { - const { state, plugin } = createState([4, undefined]); - const tr = state.tr; - expect(splitBlockTr(tr, 3, true, true)).toBe(true); - const next = state.apply(tr); - expect( - plugin - .getState(next)! - .decorations.find() - .map((decoration) => decoration.spec.index), - ).toEqual([4, 5, 6]); -}); diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index dc83a6bb66..0222fff46a 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -28,12 +28,7 @@ function calculateListItemIndex( tr: Transaction, map: Map, ): { index: number; isFirst: boolean; hasStart: boolean } { - const start = node.firstChild!.attrs["start"]; - const hasStart = typeof start === "number" && Number.isFinite(start); - if (hasStart) { - map.set(node, start); - return { index: start, isFirst: true, hasStart }; - } + const hasStart = !!node.firstChild!.attrs["start"]; // Fast path: previous sibling already in cache const blockInfo = getBlockInfoFromNode(node, pos); @@ -69,12 +64,6 @@ function calculateListItemIndex( break; } chain.push({ node: curNode, pos: curBeforePos - curNode.nodeSize }); - // A restart is the beginning of this numbering sequence, even when it - // follows another numbered item and has not been visited in this traversal. - const curStart = curNode.firstChild!.attrs["start"]; - if (typeof curStart === "number" && Number.isFinite(curStart)) { - break; - } const nextPrev = tr.doc.resolve(curInfo.block.beforePos).nodeBefore; curBeforePos = curInfo.block.beforePos; curNode = nextPrev; @@ -96,13 +85,12 @@ function calculateListItemIndex( ? map.get(predecessorNode) : undefined; - const chainStart = lastInChain.node.firstChild!.attrs["start"]; - if (predecessorIndex !== undefined && chainStart === undefined) { + if (predecessorIndex !== undefined) { index = predecessorIndex; isFirst = false; } else { // Start of a new list - index = (chainStart ?? 1) - 1; + index = (lastInChain.node.firstChild!.attrs["start"] || 1) - 1; isFirst = true; } diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 141bb107f6..57530d741b 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -52,18 +52,11 @@ export const createNumberedListItemBlockSpec = createBlockSpec( parent.tagName === "OL" || (parent.tagName === "DIV" && parent.parentElement?.tagName === "OL") ) { - const list = parent.tagName === "OL" ? parent : parent.parentElement!; - const firstItem = parent === list ? element : parent; - const start = list.getAttribute("start"); - const startIndex = Number(start); + const startIndex = parseInt(parent.getAttribute("start") || "1"); const defaultProps = parseDefaultProps(element); - if ( - firstItem.previousElementSibling || - start === null || - !Number.isSafeInteger(startIndex) - ) { + if (element.previousElementSibling || startIndex === 1) { return defaultProps; } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index dbbee9ad1e..ddda8786f3 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -10,7 +10,6 @@ import { Node, Schema } from "prosemirror-model"; import type { BlockPlacement } from "../api/getBlockInfoFromPos.js"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; -import type { HTMLPreprocessor } from "../api/parsers/html/preprocessHTML.js"; import { Block, BlockNoteSchema, @@ -247,17 +246,6 @@ export interface BlockNoteEditorOptions< }) => boolean | undefined; }) => boolean | undefined; - /** - * Transforms HTML before parsing in `tryParseHTMLToBlocks` and `pasteHTML`, - * including clipboard pastes. Defaults to `defaultHTMLPreprocessor`, which - * normalizes Microsoft Word and Google Docs HTML. Set to `false` to disable - * source normalization, or provide a function to replace it. Import and call - * `defaultHTMLPreprocessor` from your function to extend the defaults. - * - * Not applied to `pasteHTML(html, true)` or internal BlockNote clipboard HTML. - */ - preprocessHTML?: HTMLPreprocessor | false; - /** * Resolve a URL of a file block to one that can be displayed or downloaded. This can be used for creating authenticated URL or * implementing custom protocols / schemes @@ -632,10 +620,7 @@ export class BlockNoteEditor< // Initialize managers this._blockManager = new BlockManager(this as any); - this._exportManager = new ExportManager( - this as any, - options.preprocessHTML, - ); + this._exportManager = new ExportManager(this as any); this._selectionManager = new SelectionManager(this as any); this._stateManager = new StateManager(this as any); this._styleManager = new StyleManager(this as any); diff --git a/packages/core/src/editor/managers/ExportManager.ts b/packages/core/src/editor/managers/ExportManager.ts index 32095d468c..3fe1ee2f0e 100644 --- a/packages/core/src/editor/managers/ExportManager.ts +++ b/packages/core/src/editor/managers/ExportManager.ts @@ -2,7 +2,6 @@ import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTM import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer.js"; import { blocksToMarkdown } from "../../api/exporters/markdown/markdownExporter.js"; import { HTMLToBlocks } from "../../api/parsers/html/parseHTML.js"; -import { HTMLPreprocessor } from "../../api/parsers/html/preprocessHTML.js"; import { markdownToBlocks, markdownToHTML, @@ -26,10 +25,7 @@ export class ExportManager< ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema, > { - constructor( - private editor: BlockNoteEditor, - private preprocessHTML?: HTMLPreprocessor | false, - ) {} + constructor(private editor: BlockNoteEditor) {} /** * Exports blocks into a simplified HTML string. To better conform to HTML standards, children of blocks which aren't list @@ -77,7 +73,7 @@ export class ExportManager< public tryParseHTMLToBlocks( html: string, ): Block[] { - return HTMLToBlocks(html, this.editor.pmSchema, this.preprocessHTML); + return HTMLToBlocks(html, this.editor.pmSchema); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 119b8fc26b..5a1fda3424 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,7 +59,6 @@ export * from "./api/nodeConversions/nodeToBlock.js"; // for server-util (TODO: maybe move): export * from "./api/exporters/markdown/markdownExporter.js"; export * from "./api/parsers/html/parseHTML.js"; -export * from "./api/parsers/html/preprocessHTML.js"; export * from "./api/parsers/markdown/parseMarkdown.js"; // TODO: for ai, remove? diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json index fa582e4a8f..801547eba0 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/googleDocs.json @@ -471,7 +471,14 @@ Hard Break", }, { "children": [], - "content": [], + "content": [ + { + "styles": {}, + "text": " +", + "type": "text", + }, + ], "id": "21", "props": { "backgroundColor": "default", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json index 798d251ccb..b7eb0a7de4 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json @@ -89,7 +89,13 @@ }, { "children": [], - "content": [], + "content": [ + { + "styles": {}, + "text": " ", + "type": "text", + }, + ], "id": "6", "props": { "backgroundColor": "default",