facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
19.29k stars 1.63k forks source link

Bug: Preserve classes in HTML when generating nodes in Lexical #6210

Closed Patoreek closed 5 days ago

Patoreek commented 3 months ago

I currently have an editor setup with bulleted lists that also have custom styling which is applied with a custom class for example: "custom-list-9". This displays with the correct styling in the editor and is stored in the database like so:

<ul class="custom-list-9 PlaygroundEditorTheme__ul">
<li value="1" class="PlaygroundEditorTheme__ltr" dir="ltr" style="text-align: justify;"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List Item 1</span></li>
<li value="2" class="PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List Item 2</span></li>
<li value="3" class="PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List item 3</span></li>
<li value="4" class="PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List item 4</span></li>
</ul>

My issue comes from passing this content back to the editor and the generating of Nodes. It seems that all the custom styles from the elements are purged or lost in the conversion to nodes.

This is the content after it has been generated into Nodes.

<ul class="PlaygroundEditorTheme__ul">
<li value="1" class="PlaygroundEditorTheme__ltr" dir="ltr" style="text-align: justify;"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List Item 1</span></li>
<li value="2" class="PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List Item 2</span></li>
<li value="3" class="PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List item 3</span></li>
<li value="4" class="PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true" style="background-color: transparent; color: rgb(0, 0, 0);">List item 4</span></li>
</ul>

I have tried to extend the TextNode to somehow transfer custom classes across and into the corresponding Node with no luck.

My current extended TextNode is like so:

import { $isTextNode, TextNode } from "lexical";

export default class ExtendedTextNode extends TextNode {
  constructor(text, key) {
    console.log('textNode Extended init');
    super(text, key);
  }

  static getType() {
    return "extended-text";
  }

  static clone(node) {
    return new ExtendedTextNode(node.__text, node.__key);
  }

  static importDOM() {
    const importers = TextNode.importDOM();
    return {
      ...importers,
      span: () => ({
        conversion: patchStyleConversion(importers?.span),
        priority: 1,
      }),
    };
  }

  static importJSON(serializedNode) {
    return TextNode.importJSON(serializedNode);
  }

  exportJSON() {
    return super.exportJSON();
  }
}

function patchStyleConversion(originalDOMConverter) {
  console.log("patchStyleConversion");

  return (node) => {
    const original = originalDOMConverter?.(node);
    if (!original) {
      return null;
    }
    const originalOutput = original.conversion(node);

    if (!originalOutput) {
      return originalOutput;
    }

    const backgroundColor = node.style.backgroundColor;
    const color = node.style.color;

    return {
      ...originalOutput,
      forChild: (lexicalNode, parent) => {
        const originalForChild = originalOutput?.forChild ?? ((x) => x);
        const result = originalForChild(lexicalNode, parent);
        if ($isTextNode(result)) {
          const style = [
            backgroundColor ? `background-color: ${backgroundColor}` : null,
            color ? `color: ${color}` : null,
          ]
            .filter((value) => value != null)
            .join("; ");
          if (style.length) {
            return result.setStyle(style);
          }
        }
        return result;
      },
    };
  };
}

The patchStyleConversion seems to focus on updating style while it would be ideal for it to transfer the original dom elements classes across into the editor.

Another example is generating classes that can be used for line-spacing or paragraph-spacing. classes are added into the editor however when generatingNodes these classes are also 'lost'

Any direction or advice would be great!

The current behavior

The expected behavior

fantactuka commented 3 months ago

How those custom classes are applied in the first place? Is it a custom list node or some plugin?

Patoreek commented 3 months ago

Hey @fantactuka, these classes are coming custom plugin

Here is the file for the custom list plugin

import * as React from "react";

import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";

import "./custom-list.scss";

import Image from "next/image";
import {
  $getSelection,
  COMMAND_PRIORITY_EDITOR,
  createCommand,
  LexicalCommand,
  LexicalEditor,
  ParagraphNode,
  ElementNode,
} from "lexical";

import {
  INSERT_UNORDERED_LIST_COMMAND,
  INSERT_ORDERED_LIST_COMMAND,
  ListItemNode,
  $createListItemNode,
  $createListNode,
} from "@lexical/list";

// create command
const INSERT_CUSTOM_UNORDERED_LIST_COMMAND: LexicalCommand<string> = createCommand();
const INSERT_CUSTOM_ORDERED_LIST_COMMAND: LexicalCommand<string> = createCommand();

export function ShowBulletPoints({
  activeEditor,
  bulletClass,
  setBulletClass,
}: {
  activeEditor: LexicalEditor;
  bulletClass: string;
  setBulletClass: React.Dispatch<React.SetStateAction<string>>;
}): JSX.Element {
  const [editor] = useLexicalComposerContext();

  const bulletListDropDown = (list) => {
    setBulletClass(list);
    insertUnorderedList(list);
  };

  // Function to create list nodes
  const createCustomListNode = (targetNode, payload) => {
    editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);

    setTimeout(() => {
      if (targetNode) {
        let targetElement = editor.getElementByKey(targetNode.getKey());

        if (targetElement) {
          // Find the top-level ul element
          let topLevelUl = targetElement.closest("ul");

          // Check if the ul is top-level (does not have a parent li)
          if (topLevelUl && !topLevelUl.closest("li")) {
            // Remove all classes from the top-level ul
            const topLevelClassListArray = Array.from(topLevelUl.classList);
            topLevelClassListArray.forEach((className) => {
              if (topLevelUl) topLevelUl.classList.remove(className);
            });

            // Add the payload class to the top-level ul
            topLevelUl.classList.add(payload);
          }
        }
      }
    }, 0);
  };

  editor.registerCommand(
    INSERT_CUSTOM_UNORDERED_LIST_COMMAND,
    (payload) => {
      const selection = $getSelection();
      const nodes = selection?.getNodes();
      if (!nodes) {
        return false;
      }
      nodes.forEach((node) => {
        let targetNode;

        // Determine the target node
        if (node.getParent() instanceof ListItemNode) {
          targetNode = node.getParent();
        } else if (node instanceof ListItemNode) {
          targetNode = node;
        } else {
          createCustomListNode(node, payload);
          return;
        }

        if (targetNode) {
          let targetElement = editor.getElementByKey(targetNode.getKey());

          if (targetElement) {
            // Find the top-level ul element
            let topLevelUl = targetElement.closest("ul");

            // Check if the ul is top-level (does not have a parent li)
            if (topLevelUl && !topLevelUl.closest("li")) {
              // Remove all classes from the top-level ul
              const topLevelClassListArray = Array.from(topLevelUl.classList);
              topLevelClassListArray.forEach((className) => {
                if (topLevelUl) topLevelUl.classList.remove(className);
              });

              // Add the payload class to the top-level ul
              // topLevelUl.classList.add(payload);
              topLevelUl.className = payload;
            }
          }
        }
      });
      return true;
    },
    COMMAND_PRIORITY_EDITOR
  );

  const insertUnorderedList = (list) => {
    editor.dispatchCommand(INSERT_CUSTOM_UNORDERED_LIST_COMMAND, list);
  };

  const bulletPoints = Array.from({ length: 17 }, (_, index) => ({
    className: `custom-list-${index + 1}`,
    src: `/img/Bullets/Bullet-${index + 1}.svg`,
    active: bulletClass === `custom-list-${index + 1}` ? true : false,
  }));

  return (
    <div className="flex justify-center items-center gap-6 flex-wrap w-80 p-2">
      <div className="w-full">Bullet Library</div>
      {bulletPoints.map((bullet, index) => {
        return (
          <div
            key={index}
            className={`${bullet.className === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
            onClick={() => bulletListDropDown(bullet.className)}
          >
            <Image
              src={bullet.src}
              className="cursor-pointer h-6 w-5"
              alt={`bullet-${index + 1}`}
              width={20}
              height={20}
            />
          </div>
        );
      })}
      <div className="w-full">Multi-level List</div>
      <div
        className={`${"multi-level-list-1" === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
        onClick={() => bulletListDropDown("multi-level-list-1")}
      >
        <Image
          src={"/img/Multi Level List/Multi-Level-List-1.svg"}
          className="cursor-pointer h-16 w-16"
          alt={`multi-level 1`}
          width={20}
          height={20}
        />
      </div>
      <div
        className={`${"multi-level-list-2" === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
        onClick={() => bulletListDropDown("multi-level-list-2")}
      >
        <Image
          src={"/img/Multi Level List/Multi-Level-List-2.svg"}
          className="cursor-pointer h-16 w-16"
          alt={`multi-level 1`}
          width={20}
          height={20}
        />
      </div>
      <div
        className={`${"multi-level-list-3" === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
        onClick={() => bulletListDropDown("multi-level-list-3")}
      >
        <Image
          src={"/img/Multi Level List/Multi-Level-List-3.svg"}
          className="cursor-pointer h-16 w-16"
          alt={`multi-level 1`}
          width={20}
          height={20}
        />
      </div>
    </div>
  );
}

export function ShowNumberedPoints({
  activeEditor,
  bulletClass,
  setBulletClass,
}: {
  activeEditor: LexicalEditor;
  bulletClass: string;
  setBulletClass: React.Dispatch<React.SetStateAction<string>>;
}): JSX.Element {
  const [editor] = useLexicalComposerContext();
  const numberListDropDown = (list) => {
    setBulletClass(list);
    insertOrderedList(list);
  };

  const createCustomListNode = (targetNode, payload) => {
    editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);

    setTimeout(() => {
      if (targetNode) {
        let targetElement = editor.getElementByKey(targetNode.getKey());

        if (targetElement) {
          // Find the top-level ul element
          let topLevelUl = targetElement.closest("ul");

          // Check if the ul is top-level (does not have a parent li)
          if (topLevelUl && !topLevelUl.closest("li")) {
            // Remove all classes from the top-level ul
            const topLevelClassListArray = Array.from(topLevelUl.classList);
            topLevelClassListArray.forEach((className) => {
              if (topLevelUl) topLevelUl.classList.remove(className);
            });

            // Add the payload class to the top-level ul
            topLevelUl.classList.add(payload);
          }
        }
      }
    }, 0);
  };

  editor.registerCommand(
    INSERT_CUSTOM_ORDERED_LIST_COMMAND,
    (payload) => {
      const selection = $getSelection();
      const nodes = selection?.getNodes();
      if (!nodes) {
        return false;
      }

      nodes.forEach((node) => {
        let targetNode;
        const parentNode = node.getParent();

        if (parentNode instanceof ListItemNode) {
          targetNode = parentNode;
        } else if (node instanceof ListItemNode) {
          targetNode = node;
        } else {
          createCustomListNode(node, payload);
          return;
        }

        if (targetNode) {
          let targetElement = editor.getElementByKey(targetNode.getKey());

          if (targetElement) {
            let topLevelElement = targetElement.closest("ul");
            if (topLevelElement && !topLevelElement.closest("li")) {
              const topLevelClassListArray = Array.from(topLevelElement.classList);
              topLevelClassListArray.forEach((className) => {
                if (topLevelElement) topLevelElement.classList.remove(className);
              });

              topLevelElement.className = payload;
            }
          }
        }
      });

      nodes.forEach((node) => {
        let targetNode;
        const parentNode = node.getParent();

        if (parentNode instanceof ListItemNode) {
          targetNode = parentNode;
        } else if (node instanceof ListItemNode) {
          targetNode = node;
        } else {
          createCustomListNode(node, payload);
          return;
        }

        if (targetNode) {
          let targetElement = editor.getElementByKey(targetNode.getKey());

          if (targetElement) {
            let topLevelElement = targetElement.closest("ol");

            if (topLevelElement && !topLevelElement.closest("li")) {
              const topLevelClassListArray = Array.from(topLevelElement.classList);
              topLevelClassListArray.forEach((className) => {
                if (topLevelElement) topLevelElement.classList.remove(className);
              });

              topLevelElement.classList.add(payload);
            }
          }
        }
      });

      return true;
    },
    COMMAND_PRIORITY_EDITOR
  );

  const insertOrderedList = (list) => {
    editor.dispatchCommand(INSERT_CUSTOM_ORDERED_LIST_COMMAND, list);
  };

  const bulletPoints = Array.from({ length: 15 }, (_, index) => ({
    className: `number-list-${index + 1}`,
    src: `/img/Numbers/Numbering-Library-${index + 1}.svg`,
    active: bulletClass === `number-list-${index + 1}` ? true : false,
  }));

  return (
    <div className="flex justify-start items-center gap-6 flex-wrap w-[500px] p-2">
      <div className="w-full text-grey-500">Number List</div>
      {bulletPoints.map((bullet, index) => (
        <div
          key={index}
          className={`${bullet.className === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
          onClick={() => numberListDropDown(bullet.className)}
        >
          <Image
            src={bullet.src}
            className="cursor-pointer h-16 w-16"
            alt={`Numbering-Library-${index + 1}`}
            width={20}
            height={20}
          />
        </div>
      ))}
      <div className="w-full text-grey-500">Multi-level List</div>
      <div
        className={`${"multi-level-list-1" === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
        onClick={() => numberListDropDown("multi-level-list-1")}
      >
        <Image
          src={"/img/Multi Level List/Multi-Level-List-1.svg"}
          className="cursor-pointer h-16 w-16"
          alt={`multi-level 1`}
          width={20}
          height={20}
        />
      </div>
      <div
        className={`${"multi-level-list-2" === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
        onClick={() => numberListDropDown("multi-level-list-2")}
      >
        <Image
          src={"/img/Multi Level List/Multi-Level-List-2.svg"}
          className="cursor-pointer h-16 w-16"
          alt={`multi-level 1`}
          width={20}
          height={20}
        />
      </div>
      <div
        className={`${"multi-level-list-3" === bulletClass ? "border-2 border-grey-600" : ""} p-1`}
        onClick={() => numberListDropDown("multi-level-list-3")}
      >
        <Image
          src={"/img/Multi Level List/Multi-Level-List-3.svg"}
          className="cursor-pointer h-16 w-16"
          alt={`multi-level 1`}
          width={20}
          height={20}
        />
      </div>
    </div>
  );
}

export function insertDefaultNumberedList(editor: LexicalEditor) {
  const payload = "number-list-3";
  editor.update(() => {
    const selection = $getSelection();
    const nodes = selection?.getNodes();

    // Function to create list nodes
    const createCustomListNode = (targetNode, payload) => {
      editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);

      setTimeout(() => {
        if (targetNode) {
          let targetElement = editor.getElementByKey(targetNode.getKey());

          if (targetElement) {
            // Find the top-level ul element
            let topLevelUl = targetElement.closest("ul");

            // Check if the ul is top-level (does not have a parent li)
            if (topLevelUl && !topLevelUl.closest("li")) {
              // Remove all classes from the top-level ul
              const topLevelClassListArray = Array.from(topLevelUl.classList);
              topLevelClassListArray.forEach((className) => {
                if (topLevelUl) topLevelUl.classList.remove(className);
              });

              // Add the payload class to the top-level ul
              topLevelUl.classList.add(payload);
            }
          }
        }
      }, 0);
    };

    if (!nodes) {
      return false;
    }
    nodes.forEach((node) => {
      createCustomListNode(node, payload);
    });
  });
}

Classes do get applied to node, classes persist when saving content into a database, however, the content being passed into the editor then forgets any additional classes in the HTML elements and only includes the theme classes that are applied to the respective elements.

etrepum commented 3 months ago

Lexical does not really preserve anything that is added directly to the DOM (there are a few exceptions for things related to user input like selection and text that can get reconciled from a DOM update). The node needs to directly store anything you want to preserve, and createDOM/updateDOM should be used to reconcile the node state to the DOM.

GermanJablo commented 5 days ago

From what @etrepum said, I think this issue can be closed.