Repository: facebook/lexical (TypeScript)

Issue:
Bug: $generateNodesFromDOM() removes spaces around <Mark> Node
I have created a custom Mark node simular to original, that can be serialized/deserialized and stored in data base. After updating from 0.9.1 to 0.11.1 a bug appeared that seems to remove spaces around Mark node. 

Lexical version: 0.11.1

## Steps To Reproduce
1. Convert Node to MarkNode
2. Store changes in database as HTML
3. Next time Lexical updates editor from HTML -> Lexical using $generateNodesFromDOM() spaces around Mark node will be removed.

## The current behavior
<img width="332" alt="image" src="https://github.com/facebook/lexical/assets/50548714/33e773bc-ce64-46af-80f9-7d02fc150f1c">

## The expected behavior
<img width="354" alt="image" src="https://github.com/facebook/lexical/assets/50548714/5ae87986-26d8-45d2-9584-893e351d7512">



Discussion:
I have the same issue. I think it is related to this line in changelog

> Remove redundant spaces on paste

I have custom inline DecoratorNode and `$generateNodesFromDOM` removes the spaces.
I also tested that it works correctly on v0.10.0
Any update on this? Still having this issue with 0.12.5
I think I figured out the problem. The list of inline parent nodes doesn't include `mark`, so LexicalTextNode strips the ending whitespace for the previous node, and the preceding whitespace for the next node.

https://github.com/facebook/lexical/blob/8a4ac97b761f6bb8de28aceb731cee97612cc465/packages/lexical/src/nodes/LexicalTextNode.ts#L1254
This still happens in `0.24.1-nightly.20250219.0`, even though the TextNode class has been substantially rewritten since the last post.

Here's a simple reproduction:
```javascript
const dom = new DOMParser().parseFromString(
    `<html><body>Foo <mark>Bar</mark> !</body></html>`,
    'text/html',
);
const nodes = $generateNodesFromDOM(editor, dom);
```
This outputs:
```html
<span data-lexical-text="true">Foo</span><mark data-lexical-text="true"><span>Bar</span></mark><span data-lexical-text="true">!</span>
```
Note the missing spaces on either side of `Bar`.
Digging into this a bit more... it's related to how HTML is turned into Lexical nodes.

Take e.g. [this code](https://github.com/klim-type-foundry/lexical/blob/940435d30da0a2f09d5e3eda2e0a1eeb73923554/packages/lexical/src/__tests__/unit/HTMLCopyAndPaste.test.ts#L47-L54) from the HTMLCopyAndPaste tests:
```javascript
{
  expectedHTML: `<p><span data-lexical-text="true">123</span></p><p><span data-lexical-text="true">456</span></p>`,
  name: 'a single div',
  pastedHTML: `123
    <div>
      456
    </div>`,
},
```
So here, the spaces around `123` and `456` are removed when turning the pasted HTML into Lexical nodes. This is correct in this specific case, as there are also newlines/line breaks in the mix.

I'll attempt a fix based on detecting line breaks in the node, and if there are no line breaks, then the spaces likely should _not_ be stripped.
Lexical is quite opinionated about stripping spaces, so I'm hesitant to attempt a fix in the core code.

Instead, here's a workaround. Create a custom TextNode, which patches the text conversion to allow spaces to remain. This hasn't been rigorously tested, so please do test this with your code before using. Works For Me™.

```typescript
function patchTextConversion(
    originalDOMConverter?: (node: HTMLElement) => DOMConversion | null,
): (node: HTMLElement) => DOMConversionOutput | null {
    return (node) => {
        // If the node's textContent consists of only spaces, which would normally
        // be stripped by Lexical's `$convertTextDOMNode` function, return a custom
        // text node with the original textContent.
        if (node.textContent && /^ +$/.test(node.textContent)) {
            return {
                node: $createCustomTextNode(node.textContent),
            };
        }
        const originalConversion =
            originalDOMConverter?.(node)?.conversion(node) ?? null;
        if (originalConversion && node?.textContent !== null) {
            // Restore original text contents
            originalConversion.node = $createCustomTextNode(node.textContent);
        }
        return originalConversion;
    };
}

class CustomTextNode extends TextNode {
    static getType() {
        return 'custom-text';
    }
    constructor(text: string = '', key?: NodeKey) {
        super(text, key);
    }
    static clone(node: CustomTextNode): CustomTextNode {
        return new CustomTextNode(node.__text, node.__key);
    }
    static importDOM(): DOMConversionMap | null {
        const importers = TextNode.importDOM();
        const textImporter = importers ? importers['#text'] : undefined;
        return {
            ...importers,
            '#text': () => ({
                conversion: patchTextConversion(textImporter),
                priority: 1,
            }),
        };
    }
    static importJSON(serializedNode: SerializedTextNode): CustomTextNode {
        return $createCustomTextNode().updateFromJSON(serializedNode);
    }
}

function $createCustomTextNode(text = ''): CustomTextNode {
    return $applyNodeReplacement(new CustomTextNode(text));
}

const EditorConfig: InitialConfigType = {
    namespace: 'FooBar',
    onError: (error: Error) => console.error(error),
    nodes: [
        CustomTextNode,
        {
            replace: TextNode,
            with: (node: TextNode) => {
                return new CustomTextNode(node.__text);
            },
            withKlass: CustomTextNode,
        },
    ],
};

// Initialise your editor with the above config...
```
You don't need a custom node just to add an importDOM to your editor, see https://lexical.dev/docs/concepts/serialization#html-property-for-import-and-export-configuration

As for handling the whitespace around mark nodes, adding it to the regex of inline DOM nodes (in LexicalUtils.ts) might be all that's needed, but I haven't done any deeper investigation than that. This is basically the same code that used to be in TextNode per an older comment https://github.com/facebook/lexical/issues/4641#issuecomment-1881890332

```ts
export function isInlineDomNode(node: Node) {
  const inlineNodes = new RegExp(
    /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
    'i',
  );
  return node.nodeName.match(inlineNodes) !== null;
}
```
Legend @etrepum. Patch to add `mark` to `isInlineDomNode` is exactly what is needed, and passes all existing unit tests.

PR incoming.
Great to hear! To ensure this continues to work in the future, it would be good to add a unit test that verifies that your PR does exactly what it's supposed to
Definitely, I've added a test, @etrepum. PR ^

Thanks again for the most excellent pointer.



Patch:
diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts
index 70ff7569bf5..691b4be0bcc 100644
--- a/packages/lexical/src/LexicalUtils.ts
+++ b/packages/lexical/src/LexicalUtils.ts
@@ -1813,7 +1813,7 @@ export function isDocumentFragment(x: unknown): x is DocumentFragment {
  */
 export function isInlineDomNode(node: Node) {
   const inlineNodes = new RegExp(
-    /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
+    /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|mark|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
     'i',
   );
   return node.nodeName.match(inlineNodes) !== null;


Test patch:
diff --git a/packages/lexical-mark/__tests__/unit/LexicalMarkNode.test.ts b/packages/lexical-mark/__tests__/unit/LexicalMarkNode.test.ts
index 3caa0c79525..1acf7183381 100644
--- a/packages/lexical-mark/__tests__/unit/LexicalMarkNode.test.ts
+++ b/packages/lexical-mark/__tests__/unit/LexicalMarkNode.test.ts
@@ -5,6 +5,7 @@
  * LICENSE file in the root directory of this source tree.
  *
  */
+import {$generateNodesFromDOM} from '@lexical/html';
 import {$wrapSelectionInMarkNode, MarkNode} from '@lexical/mark';
 import {
   $createParagraphNode,
@@ -12,6 +13,7 @@ import {
   $createTextNode,
   $getRoot,
   ParagraphNode,
+  TextNode,
 } from 'lexical';
 import {
   $createTestDecoratorNode,
@@ -181,5 +183,38 @@ describe('LexicalMarkNode tests', () => {
         });
       });
     });
+    describe('$generateNodesFromDOM', () => {
+      beforeEach(() => {
+        testEnv.editor.update(
+          () => {
+            $getRoot().clear();
+          },
+          {discrete: true},
+        );
+      });
+
+      test('retains spaces around mark elements', () => {
+        const {editor} = testEnv;
+
+        editor.update(() => {
+          const dom = new DOMParser().parseFromString(
+            `<html><body><p><span>Foo </span><mark>Bar</mark><span> !</span></p></body></html>`,
+            'text/html',
+          );
+          const nodes = $generateNodesFromDOM(editor, dom);
+
+          expect(nodes).toHaveLength(1);
+          const paragraphNode = nodes[0] as ParagraphNode;
+          expect(paragraphNode.getChildren()).toHaveLength(3);
+          const textNode1 = paragraphNode.getChildAtIndex(0) as TextNode;
+          const markNode = paragraphNode.getChildAtIndex(1) as MarkNode;
+          const textNode2 = paragraphNode.getChildAtIndex(2) as TextNode;
+
+          expect(textNode1.getTextContent()).toEqual('Foo ');
+          expect(markNode.getTextContent()).toEqual('Bar');
+          expect(textNode2.getTextContent()).toEqual(' !');
+        });
+      });
+    });
   });
 });