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
\nLast
',
+ );
+ 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 = "";
+ 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;
}