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 d4ca058799..5972397392 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<