Skip to main content

Slash commands

This tutorial will demonstrate how to create a feature that adds slash commands to the editor. With this feature, a dropdown will be opened when the user types / in the editor. The dropdown will contain a list of commands that the user can select from.

Creating the plugin

The plugin will be built on top of the EditorLookupDropdownBase component. For more information about the EditorLookupDropdownBase component, see the Lookup dropdown page.

As described in the lookup dropdown page, we first need to create a regex based trigger function that will be used to detect when the user types a slash command. The trigger function will be used to open the dropdown when the user types /.

import { MenuTextMatch } from "@lexical/react/LexicalTypeaheadMenuPlugin";

const PUNCTUATION =
"\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;";

const TRIGGERS = ["/"].join("");

// Chars we expect to see in a slash command (non-space, non-punctuation).
const VALID_CHARS = `[^${TRIGGERS}${PUNCTUATION}\\s]`;

const LENGTH_LIMIT = 75;

const SlashRegex = new RegExp(
"(^|\\s|\\()(" +
`[${TRIGGERS}]` +
`((?:${VALID_CHARS}){0,${LENGTH_LIMIT}})` +
")$"
);

export function checkForSlashes(
text: string,
minMatchLength: number
): MenuTextMatch | null {
const match = SlashRegex.exec(text);

if (match !== null) {
// The strategy ignores leading whitespace but we need to know it's
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[1];

const matchingString = match[3];
if (matchingString.length >= minMatchLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
};
}
}
return null;
}

function getPossibleQueryMatch(text: string): MenuTextMatch | null {
return checkForSlashes(text, 0);
}

Now we can create the component that uses EditorLookupDropdownBase to create the dropdown. The component will be responsible for rendering the dropdown and handling the selection of items from the dropdown.

import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $getSelection, $isTextNode } from "lexical";
import { EditorLookupDropdownBase } from "@sparrowengg/twigs-react";

export const SlashesPlugin = () => {
const [editor] = useLexicalComposerContext();
const commands = [
{ label: "Show alert", value: "show-alert" },
{ label: "Show prompt", value: "show-prompt" },
{ label: "Show confirm", value: "show-confirm" },
];

return (
<EditorLookupDropdownBase
getResults={(searchString) => {
return commands.filter((command) =>
command.label
.toLowerCase()
.includes((searchString || "").toLowerCase())
);
}}
triggerFunction={(text) => getPossibleQueryMatch(text)}
onMenuItemSelect={(item, closeMenu) => {
let promptValue = '';
// Perform different action based on the selected command
if (item.data.value === "show-alert") {
alert("Alert!");
} else if (item.data.value === "show-prompt") {
promptValue = prompt("Prompt!");
} else if (item.data.value === "show-confirm") {
confirm("Confirm!");
}
// You can also make changes to the editor content based on a command
// This can also include inserting custom nodes, such as MentionNode
editor.update(() => {
const selection = $getSelection();
const nodes = selection?.getNodes();
if (nodes?.[0] && $isTextNode(nodes[0])) {
if (promptValue) {
nodes[0].setTextContent(promptValue);
} else {
nodes[0].setTextContent('');
}
}
});
closeMenu();
// Return true means that we don't want to perform the default action after menu item selection
return true;
}}
/>
);
};

Putting it all together

Result
Loading...
const PUNCTUATION =
  "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;";

const TRIGGERS = ["/"].join("");
// Chars we expect to see in a slash command (non-space, non-punctuation).
const VALID_CHARS = `[^${TRIGGERS}${PUNCTUATION}\\s]`;
const LENGTH_LIMIT = 75;

const SlashRegex = new RegExp(
  "(^|\\s|\\()(" +
    `[${TRIGGERS}]` +
    `((?:${VALID_CHARS}){0,${LENGTH_LIMIT}})` +
    ")$"
);

function checkForSlashes(text, minMatchLength) {
  const match = SlashRegex.exec(text);
  if (match !== null) {
    // The strategy ignores leading whitespace but we need to know it's
    // length to add it to the leadOffset
    const maybeLeadingWhitespace = match[1];
    const matchingString = match[3];
    if (matchingString.length >= minMatchLength) {
      return {
        leadOffset: match.index + maybeLeadingWhitespace.length,
        matchingString,
        replaceableString: match[2],
      };
    }
  }

  return null;
}

function getPossibleQueryMatch(text) {
  return checkForSlashes(text, 0);
}

const SlashesPlugin = () => {
  const [editor] = useLexicalComposerContext();

  const commands = [
    { label: "Show alert", value: "show-alert" },
    { label: "Show prompt", value: "show-prompt" },
    { label: "Show confirm", value: "show-confirm" },
  ];

  return (
    <EditorLookupDropdownBase
      getResults={(searchString) => {
        return commands.filter((command) =>
          command.label
            .toLowerCase()
            .includes((searchString || "").toLowerCase())
        );
      }}
      triggerFunction={(text) => getPossibleQueryMatch(text)}
      onMenuItemSelect={(item, closeMenu) => {
        let promptValue = "";
        // Perform different action based on the selected command
        if (item.data.value === "show-alert") {
          alert("Alert!");
        } else if (item.data.value === "show-prompt") {
          promptValue = prompt("Prompt!");
        } else if (item.data.value === "show-confirm") {
          confirm("Confirm!");
        }
        // You can also make changes to the editor content based on a command
        // This can also include inserting custom nodes, such as MentionNode
        editor.update(() => {
          const selection = $getSelection();
          const nodes = selection.getNodes();
          if (nodes.length > 0 && nodes[0] && $isTextNode(nodes[0])) {
            if (promptValue) {
              nodes[0].setTextContent(promptValue);
            } else {
              nodes[0].setTextContent("");
            }
          }
        });
        closeMenu();
        // Returning true means that we don't want to perform the default action after menu item selection
        return true;
      }}
    />
  );
};

const Demo = () => {
  return (
    <Editor>
      <EditorToolbar />
      <RichEditor />
      <SlashesPlugin />
    </Editor>
  );
};

render(<Demo />);