Use Lexical Editor with Shadcn UI

Henry Yang · ·

Lexical is an extensible text editor framework that allows us to create rich text editors with ease. In this tutorial, we will create a simple rich text editor using Lexical and Shadcn UI.

Prerequisites

Before we start, make sure you have set up a React project, with tailwindcss, Lexical and Shadcn UI installed.

Here are some tutorials to help you set up your project:

We will rely on the @lexical/utils package for some utility functions.

Basic Lexical Editor

For my project, I have a simple Lexical Editor component that uses the RichTextPlugin to render the rich text editor. The RichTextPlugin requires a contentEditable prop, which is a React component that will be rendered as the content editable area.

// src/components/editor/index.tsx

// ...

const Editor: React.FC = () => {
  const config: InitialConfigType = {
    namespace: "lexical-editor",

    nodes: [
      HeadingNode,
      ListNode,
      ListItemNode,
      QuoteNode,
      CodeNode,
      CodeHighlightNode,

      AutoLinkNode,
      LinkNode,
    ],

    onError: (error) => {
      console.error(error);
    },
  };

  return (
    <LexicalComposer initialConfig={config}>
      <div
        className={`mx-auto relative prose dark:prose-invert flex flex-col mt-10 border shadow rounded-lg`}
      >
        {/* The toolbar will be added here */}

        <div className="relative">
          <RichTextPlugin
            contentEditable={
              <ContentEditable className="focus:outline-none w-full px-8 py-4 h-[500px] overflow-auto relative" />
            }
            placeholder={
              <p className="text-muted-foreground absolute top-0 px-8 py-4 w-full pointer-events-none">
                Enter some text...
              </p>
            }
            ErrorBoundary={LexicalErrorBoundary}
          />
          <HistoryPlugin />
        </div>
        {/* <AutoFocusPlugin /> */}
        <ListPlugin />
        <LinkPlugin />

        <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
      </div>
    </LexicalComposer>
  );
};

export default Editor;

export default Editor;

In the above code, we have a config object that contains the configuration for the Lexical Editor. We have defined the theme object, which contains the styles for the editor. We have also defined the nodes array, which contains the nodes that will be rendered in the editor.

The RichTextPlugin component renders the rich text editor. It requires a contentEditable prop, which is a React component that will be rendered as the content editable area. In this case, we are rendering a ContentEditable component.

The LexicalComposer component wraps the RichTextPlugin component and provides the configuration for the editor. We have also added the HistoryPlugin, ListPlugin, and LinkPlugin components to provide additional functionality to the editor.

The code will render a simple rich text editor with a content editable area and a placeholder text.

Initial Editor

Add a Toolbar

The toolbar is created using the plugin system in Lexical. The plugin could access the editor instance and render a React component.

Let’s create a new file under src/components/editor/plugins called toolbar-plugin.tsx and add the following code:

// src/components/editor/plugins/toolbar-plugin.tsx
export default function ToolbarPlugin() {
  return <div className="w-full p-2 border-b z-10">Toolbar</div>;
}

Now we need to add the ToolbarPlugin to the Editor component.

// src/components/editor/index.tsx
<LexicalComposer initialConfig={config}>
  <div
    className={`mx-auto relative prose dark:prose-invert flex flex-col mt-10 border shadow rounded-lg`}
  >
    <ToolbarPlugin />
    <div className="relative">{/* ... */}</div>
    <ListPlugin />
    <LinkPlugin />

    <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
  </div>
</LexicalComposer>

Remember to place the ToolbarPlugin component inside the LexicalComposer component. In this way, the plugin will have access to the editor instance.

Simple Toolbar Buttons

The toolbar component is ready now! Let’s try to add some simple formatting buttons to the toolbar.

// src/components/editor/plugins/toolbar-plugin.tsx
export default function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();
  const [isBold, setIsBold] = useState<boolean>(false);
  const [isItalic, setIsItalic] = useState<boolean>(false);
  const [isUnderline, setIsUnderline] = useState<boolean>(false);

  return (
    <div className="w-full p-1 border-b z-10">
      <div className="flex space-x-2 justify-center">
        <Toggle
          area-label="Bold"
          size="sm"
          pressed={isBold}
          onPressedChange={(pressed) => {
            editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
            setIsBold(pressed);
          }}
        >
          <FontBoldIcon />
        </Toggle>

        <Toggle
          area-label="Italic"
          size="sm"
          pressed={isItalic}
          onPressedChange={(pressed) => {
            editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
            setIsItalic(pressed);
          }}
        >
          <FontItalicIcon />
        </Toggle>

        <Toggle
          area-label="Underline"
          size="sm"
          pressed={isUnderline}
          onPressedChange={(pressed) => {
            editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
            setIsUnderline(pressed);
          }}
        >
          <UnderlineIcon />
        </Toggle>
      </div>
    </div>
  );
}

The logic is simple. We have three states for bold, italic, and underline. When the user clicks on the button, we dispatch the FORMAT_TEXT_COMMAND command with the corresponding format.

Toolbar Buttons

The toolbar seems to work fine. However, we could notice that when the user selects some other text (which are not bold), the bold button is still pressed. We need to update the button state based on the editor state.

Detect Format

const $updateToolbar = useCallback(() => {
  const selection = $getSelection();
  if ($isRangeSelection(selection)) {
    setIsBold(selection.hasFormat("bold"));
    setIsItalic(selection.hasFormat("italic"));
    setIsUnderline(selection.hasFormat("underline"));
  }
}, []);

useEffect(() => {
  return mergeRegister(
    editor.registerCommand(
      SELECTION_CHANGE_COMMAND,
      () => {
        $updateToolbar();
        return false;
      },
      COMMAND_PRIORITY_CRITICAL
    ),
    editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        $updateToolbar();
      });
    })
  );
}, [editor, $updateToolbar]);

In order to update the toolbar buttons based on the editor state, we need to listen to the SELECTION_CHANGE_COMMAND command. When the selection changes, we update the state of the buttons based on the format of the selected text. We also need to update the toolbar when the editor state changes.

Now the toolbar buttons should update based on the selected text. There’s still a little issue: the underline button is not working as expected. By checking the devtools, we could see that the underlined text is wrapped in a span, but no classnames are added. So we need to make some changes to the initial config.

Add Underline Node

The classnames for formatted node could be customized with theme.

const config: InitialConfigType = {
  namespace: "lexical-editor",

  theme: {
    text: {
      underline: "underline",
    },
  },

  // ...
};

The theme object contains the classnames for the formatted nodes. In this case, we have added a underline classname for the underline format. The styles will be applied by @tailwindcss/typography. Now the simple buttons should work as expected!

Simple Buttons Final

Undo and Redo

The HistoryPlugin provides undo and redo functionality to the editor. In the toolbar, we could trigger undo and redo with UNDO_COMMAND and REDO_COMMAND. Also, we could listen to the CAN_UNDO_COMMAND and CAN_REDO_COMMAND commands to update the button state.

Here’s how to add undo and redo buttons to the toolbar:

export default function ToolbarPlugin() {
  // ...
  const [canUndo, setCanUndo] = useState<boolean>(false);
  const [canRedo, setCanRedo] = useState<boolean>(false);

  // ...
  useEffect(() => {
    return mergeRegister(
      editor.registerCommand(
        CAN_UNDO_COMMAND,
        (payload) => {
          setCanUndo(payload);
          return false;
        },
        COMMAND_PRIORITY_CRITICAL
      ),
      editor.registerCommand(
        CAN_REDO_COMMAND,
        (payload) => {
          setCanRedo(payload);
          return false;
        },
        COMMAND_PRIORITY_CRITICAL
      )
    );
  }, [editor]);

  // ...
  return (
    <div className="w-full border-b z-10 relative">
      <div className="flex space-x-2 justify-center p-1">
        <Button
          className="h-8 px-2"
          variant={"ghost"}
          disabled={!canUndo}
          onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
        >
          {/* reload flip to left */}
          <ReloadIcon className="transform -scale-x-100" />
        </Button>

        <Button
          className="h-8 px-2"
          variant={"ghost"}
          disabled={!canRedo}
          onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
        >
          <ReloadIcon />
        </Button>

        <Separator orientation="vertical" className="h-auto my-1" />
        {/* ... */}
      </div>
    </div>
  );
}

mergeRegister is a helper function that registers multiple commands at once. We listen to the CAN_UNDO_COMMAND and CAN_REDO_COMMAND commands to update the button state, where the payload is the updated CAN_UNDO and CAN_REDO value. When the user clicks on the undo or redo button, we dispatch the UNDO_COMMAND or REDO_COMMAND command. Notice that the second argument of the dispatchCommand function is undefined because the command does not require any payload.

Now the toolbar should look like this:

Undo Redo Buttons

Block Type Dropdown

We could add a dropdown to the toolbar to select the block Type (paragraph, heading, etc.). For code clarity, we could create a new component called BlockTypeDropdown.

// src/components/editor/plugins/components/block-type-dropdown.tsx
interface BlockTypeDropdownProps {
  blockType: keyof typeof blockTypeToBlockName;
}

export default function BlockTypeDropdown({
  blockType,
}: BlockTypeDropdownProps) {
  const [editor] = useLexicalComposerContext();

  const formatHeading = (headingLevel: HeadingTagType) => {
    editor.update(() => {
      const selection = $getSelection();
      $setBlocksType(selection, () => $createHeadingNode(headingLevel));
    });
  };

  const formatParagraph = () => {
    editor.update(() => {
      const selection = $getSelection();
      $setBlocksType(selection, () => $createParagraphNode());
    });
  };

  const formatOrderedList = () => {
    if (blockType !== "number") {
      editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
    } else {
      editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
    }
  };

  const formatUnorderedList = () => {
    if (blockType !== "bullet") {
      editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
    } else {
      editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
    }
  };

  const formatQuote = () => {
    editor.update(() => {
      const selection = $getSelection();
      $setBlocksType(selection, () => $createQuoteNode());
    });
  };

  return (
    <Select
      value={blockType}
      onValueChange={(value) => {
        switch (value) {
          case "h1":
            formatHeading("h1");
            break;
          case "h2":
            formatHeading("h2");
            break;
          case "h3":
            formatHeading("h3");
            break;
          case "h4":
            formatHeading("h4");
            break;
          case "h5":
            formatHeading("h5");
            break;
          case "h6":
            formatHeading("h6");
            break;
          case "paragraph":
            formatParagraph();
            break;
          case "number":
            formatOrderedList();
            break;
          case "bullet":
            formatUnorderedList();
            break;
          case "quote":
            formatQuote();
            break;
        }
      }}
    >
      <SelectTrigger className="w-40">
        <SelectValue placeholder="Block Type" />
      </SelectTrigger>

      <SelectContent>
        {Object.keys(blockTypeToBlockName).map((blockType) => {
          return (
            <SelectItem key={blockType} value={blockType}>
              {blockTypeToBlockName[blockType]}
            </SelectItem>
          );
        })}
      </SelectContent>
    </Select>
  );
}

The BlockTypeDropdown component receives the blockType prop, which is the current block type. When the user selects a block type from the dropdown, we dispatch the corresponding command to the editor.

// src/components/editor/plugins/components/block-types.ts
export const blockTypeToBlockName: Record<string, string> = {
  h1: "Heading 1",
  h2: "Heading 2",
  h3: "Heading 3",
  h4: "Heading 4",
  h5: "Heading 5",
  h6: "Heading 6",
  paragraph: "Normal",
  quote: "Quote",
  bullet: "Bulleted List",
  number: "Numbered List",
};

Now we could add the BlockTypeDropdown component to the ToolbarPlugin.

// ...
const [blockType, setBlockType] =
  useState<keyof typeof blockTypeToBlockName>("paragraph");

// ...
<BlockTypeDropdown blockType={blockType} />;

Also, we need to update the block type when the selection changes.

const $updateToolbar = useCallback(() => {
  const selection = $getSelection();
  if ($isRangeSelection(selection)) {
    setIsBold(selection.hasFormat("bold"));
    setIsItalic(selection.hasFormat("italic"));
    setIsUnderline(selection.hasFormat("underline"));

    const anchorNode = selection.anchor.getNode();

    let element =
      anchorNode.getKey() === "root"
        ? anchorNode
        : $findMatchingParent(anchorNode, (e) => {
            const parent = e.getParent();
            return parent !== null && $isRootOrShadowRoot(parent);
          });

    if (element === null) {
      element = anchorNode.getTopLevelElementOrThrow();
    }

    const elementDOM = editor.getElementByKey(element.getKey());

    if (elementDOM !== null) {
      if ($isListNode(element)) {
        const parentList = $getNearestNodeOfType<ListNode>(
          anchorNode,
          ListNode
        );
        const type = parentList
          ? parentList.getListType()
          : element.getListType();
        setBlockType(type);
      } else {
        const type = $isHeadingNode(element)
          ? element.getTag()
          : element.getType();
        if (type in blockTypeToBlockName) {
          setBlockType(type as keyof typeof blockTypeToBlockName);
        }
      }
    }
  }
}, [editor]);

In the above code, we update the block type based on the selected text. If the selected text is a list node, we set the block type to the list type. If the selected text is a heading node, we set the block type to the heading tag. Otherwise, we set the block type to the node type.

Now the toolbar should look like this:

Block Type Dropdown

Conclusion

In this tutorial, we have created a simple rich text editor using Lexical and Shadcn UI. We have added a toolbar with simple formatting buttons, undo and redo buttons, and a block type dropdown. We have also updated the toolbar buttons based on the editor state.

The complete code for this tutorial is available on GitHub. If you find this tutorial helpful, please give it a star! Happy coding!