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
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 />);