From 7014fd017d4a90cc174d4d9f5db6c26d5829b6c3 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 10 Mar 2026 20:11:09 +0100 Subject: [PATCH 1/2] fix(core): preserve whitespace edge cases but collapse html formatting newlines (BLO-1065) - Added a targeted DOM preprocessing step before ProseMirror parsing. - Explictly replicates CSS white-space: normal behavior internally to fix MS Word line breaks. - Retains preserveWhitespace: true in PM to satisfy AI diffing constraints from PR #2230. - Skips Notion HTML to preserve intentional hard breaks. --- .../core/src/api/parsers/html/parseHTML.ts | 2 + .../parsers/html/util/normalizeWhitespace.ts | 87 ++ .../clipboard/paste/pasteTestInstances.ts | 10 +- .../html/mixedTextTableCell.json | 5 +- .../parse/__snapshots__/html/msWordPaste.json | 126 +++ .../parse/parseTestInstances.ts | 64 ++ wordcopy.txt | 777 ++++++++++++++++++ 7 files changed, 1062 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/api/parsers/html/util/normalizeWhitespace.ts create mode 100644 tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json create mode 100644 wordcopy.txt diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts index 43f3dc4559..16e03f883a 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 { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { nestedListsToBlockNoteStructure } from "./util/nestedLists.js"; +import { preprocessHTMLWhitespace } from "./util/normalizeWhitespace.js"; export function HTMLToBlocks< BSchema extends BlockSchema, @@ -15,6 +16,7 @@ export function HTMLToBlocks< S extends StyleSchema, >(html: string, pmSchema: Schema): Block[] { const htmlNode = nestedListsToBlockNoteStructure(html); + preprocessHTMLWhitespace(htmlNode); const parser = DOMParser.fromSchema(pmSchema); // Other approach might be to use diff --git a/packages/core/src/api/parsers/html/util/normalizeWhitespace.ts b/packages/core/src/api/parsers/html/util/normalizeWhitespace.ts new file mode 100644 index 0000000000..9cacd86e15 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/normalizeWhitespace.ts @@ -0,0 +1,87 @@ +/** + * Checks if the given HTML element contains markers indicating it was + * generated by Notion. Notion uses `\n` in text nodes to represent hard + * breaks, which is non-standard but intentional. + * + * Detected by the `` comment that Notion places + * on the clipboard. + */ +function isNotionHTML(element: HTMLElement): boolean { + const walker = element.ownerDocument.createTreeWalker( + element, + // NodeFilter.SHOW_COMMENT + 128, + ); + + let node: Node | null; + while ((node = walker.nextNode())) { + if (/^\s*notionvc:/.test(node.nodeValue || "")) { + return true; + } + } + + return false; +} + +/** + * Normalizes whitespace in text nodes by collapsing runs of whitespace + * (including newlines) to single spaces, matching CSS white-space:normal + * behavior. + * + * This is needed because ProseMirror's DOMParser, when `linebreakReplacement` + * is set in the schema (as BlockNote does for hard breaks), converts `\n` + * characters in text nodes to hard break nodes instead of collapsing them. + * This causes HTML source line wrapping (e.g. from MS Word) to create + * visible line breaks in the editor. + * + * Skipped for sources like Notion that intentionally use `\n` in text nodes + * to represent hard breaks instead of `
` tags. + * + * Skips `
` and `` elements where whitespace should be preserved.
+ */
+function normalizeTextNodeWhitespace(element: HTMLElement) {
+  const preserveWSTags = new Set(["PRE", "CODE"]);
+  const walker = element.ownerDocument.createTreeWalker(
+    element,
+    // NodeFilter.SHOW_TEXT
+    4,
+    {
+      acceptNode(node) {
+        // Skip text nodes inside pre/code elements
+        let parent = node.parentElement;
+        while (parent && parent !== element) {
+          if (preserveWSTags.has(parent.tagName)) {
+            // NodeFilter.FILTER_REJECT
+            return 2;
+          }
+          parent = parent.parentElement;
+        }
+        // NodeFilter.FILTER_ACCEPT
+        return 1;
+      },
+    },
+  );
+
+  const textNodes: Text[] = [];
+  let node: Node | null;
+  while ((node = walker.nextNode())) {
+    textNodes.push(node as Text);
+  }
+
+  for (const textNode of textNodes) {
+    if (textNode.nodeValue && /[\r\n]/.test(textNode.nodeValue)) {
+      textNode.nodeValue = textNode.nodeValue.replace(/[ \t\r\n\f]+/g, " ");
+    }
+  }
+}
+
+/**
+ * Normalizes whitespace in HTML text nodes to match standard CSS
+ * white-space:normal behavior. Skipped for Notion HTML which intentionally
+ * uses `\n` for hard breaks.
+ */
+export function preprocessHTMLWhitespace(element: HTMLElement) {
+  if (!isNotionHTML(element)) {
+    normalizeTextNodeWhitespace(element);
+  }
+}
diff --git a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts
index cf9e0d33dd..0220d816d9 100644
--- a/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts
+++ b/tests/src/unit/core/clipboard/paste/pasteTestInstances.ts
@@ -1,10 +1,5 @@
 import { TextSelection } from "@tiptap/pm/state";
 
-import {
-  TestBlockSchema,
-  TestInlineContentSchema,
-  TestStyleSchema,
-} from "../../testSchema.js";
 import { PasteTestCase } from "../../../shared/clipboard/paste/pasteTestCase.js";
 import {
   testPasteHTML,
@@ -12,6 +7,11 @@ import {
 } from "../../../shared/clipboard/paste/pasteTestExecutors.js";
 import { getPosOfTextNode } from "../../../shared/testUtil.js";
 import { TestInstance } from "../../../types.js";
+import {
+  TestBlockSchema,
+  TestInlineContentSchema,
+  TestStyleSchema,
+} from "../../testSchema.js";
 
 export const pasteTestInstancesHTML: TestInstance<
   PasteTestCase,
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/mixedTextTableCell.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/mixedTextTableCell.json
index 40018a5ae2..0ee4579333 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/mixedTextTableCell.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/mixedTextTableCell.json
@@ -15,10 +15,7 @@
                 {
                   "styles": {},
                   "text": "Table Cell
-Table Cell
-        
-        Table Cell
-",
+Table Cell  Table Cell",
                   "type": "text",
                 },
               ],
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json
new file mode 100644
index 0000000000..b7eb0a7de4
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/msWordPaste.json
@@ -0,0 +1,126 @@
+[
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {
+          "bold": true,
+          "underline": true,
+        },
+        "text": "Que se passe-t-il si je réponds tard à un message chat et que l'utilisateur n'est plus en ligne :",
+        "type": "text",
+      },
+    ],
+    "id": "1",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {},
+        "text": "Lorsque vous envoyez un message à un utilisateur dans une conversation chat, et qu'il est encore en ligne, il recevra le message sur sa bulle chatbot.",
+        "type": "text",
+      },
+    ],
+    "id": "2",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {},
+        "text": "Cependant S'il n'est plus en ligne, votre message sera envoyé par email si :",
+        "type": "text",
+      },
+    ],
+    "id": "3",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {},
+        "text": ". l'utilisateur n'a pas lu votre réponse après 2 minutes",
+        "type": "text",
+      },
+    ],
+    "id": "4",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {},
+        "text": ". l'utilisateur n'est plus présent sur votre site web",
+        "type": "text",
+      },
+    ],
+    "id": "5",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {},
+        "text": " ",
+        "type": "text",
+      },
+    ],
+    "id": "6",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+  {
+    "children": [],
+    "content": [
+      {
+        "styles": {},
+        "text": "Cela se fait automatiquement donc, lorsque nous répondons par chat, si l'utilisateur n'est plus là, Crisp renvoie le message alors par email et le canal de discussion se transforme en canal de discussion email.
+ 
+ Il est possible aussi de créer une conversation email directement le profil de l'utilisateur (bouton bleu en haut à droite de la conversation)",
+        "type": "text",
+      },
+    ],
+    "id": "7",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "textColor": "default",
+    },
+    "type": "paragraph",
+  },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
index d5ad737dd5..38239be6e4 100644
--- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
+++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
@@ -949,6 +949,70 @@ console.log("Third Line")
`, }, executeTest: testParseHTML, }, + { + testCase: { + name: "msWordPaste", + content: ` + + + + + + + + + + + + +

Que se passe-t-il si je réponds tard à +un message chat et que l'utilisateur n'est plus en ligne :

+ +

Lorsque vous envoyez un message à un +utilisateur dans une conversation chat, et qu'il est encore en ligne, il +recevra le message sur sa bulle chatbot.

+ +

Cependant +S'il n'est plus en ligne, votre message sera envoyé par email si :

+ +

. +l'utilisateur n'a pas lu votre réponse après 2 minutes

+ +

. +l'utilisateur n'est plus présent sur votre site web

+ +

 

+ +

Cela se fait automatiquement donc, lorsque +nous répondons par chat, si l'utilisateur n'est plus là, Crisp renvoie le +message alors par email et le canal de discussion se transforme en canal de +discussion email.
+
+Il est possible aussi de créer une conversation email directement le profil de +l'utilisateur (bouton bleu en haut à droite de la conversation)

+ + + + +`, + }, + executeTest: testParseHTML, + }, ]; export const parseTestInstancesMarkdown: TestInstance< diff --git a/wordcopy.txt b/wordcopy.txt new file mode 100644 index 0000000000..7be9871a8a --- /dev/null +++ b/wordcopy.txt @@ -0,0 +1,777 @@ + + + + + + + + + + + + + + + + + + + +

Que se passe-t-il si je réponds tard à +un message chat et que l’utilisateur n’est plus en ligne :

+ +

Lorsque vous envoyez un message à un +utilisateur dans une conversation chat, et qu’il est encore en ligne, il +recevra le message sur sa bulle chatbot.

+ +

Cependant +S’il n’est plus en ligne, votre message sera envoyé par email si :

+ +

. +l'utilisateur n'a pas lu votre réponse après 2 minutes

+ +

. +l'utilisateur n'est plus présent sur votre site web

+ +

 

+ +

Cela se fait automatiquement donc, lorsque +nous répondons par chat, si l'utilisateur n'est plus là, Crisp renvoie le +message alors par email et le canal de discussion se transforme en canal de +discussion email.
+
+Il est possible aussi de créer une conversation email directement le profil de +l'utilisateur (bouton bleu en haut à droite de la conversation)

+ + + + + From 03b91a3d1b982c14e4f5579b1de847ae9dcb1820 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 10 Mar 2026 20:19:23 +0100 Subject: [PATCH 2/2] delete file --- wordcopy.txt | 777 --------------------------------------------------- 1 file changed, 777 deletions(-) delete mode 100644 wordcopy.txt diff --git a/wordcopy.txt b/wordcopy.txt deleted file mode 100644 index 7be9871a8a..0000000000 --- a/wordcopy.txt +++ /dev/null @@ -1,777 +0,0 @@ - - - - - - - - - - - - - - - - - - - -

Que se passe-t-il si je réponds tard à -un message chat et que l’utilisateur n’est plus en ligne :

- -

Lorsque vous envoyez un message à un -utilisateur dans une conversation chat, et qu’il est encore en ligne, il -recevra le message sur sa bulle chatbot.

- -

Cependant -S’il n’est plus en ligne, votre message sera envoyé par email si :

- -

. -l'utilisateur n'a pas lu votre réponse après 2 minutes

- -

. -l'utilisateur n'est plus présent sur votre site web

- -

 

- -

Cela se fait automatiquement donc, lorsque -nous répondons par chat, si l'utilisateur n'est plus là, Crisp renvoie le -message alors par email et le canal de discussion se transforme en canal de -discussion email.
-
-Il est possible aussi de créer une conversation email directement le profil de -l'utilisateur (bouton bleu en haut à droite de la conversation)

- - - - -