Slash menu
The slash menu plugin opens a filtered command menu when you type a slash, listing every block kind preset and every plugin command.
slashMenuPlugin opens a command menu at the caret when you type /. Typing after the slash filters it; Enter runs the highlighted command and removes the /query text.
<script lang="ts">
import { Edytor, slashMenuPlugin } from 'edytor';
</script>
<Edytor plugins={[slashMenuPlugin]} />
slashMenuPlugin is the menu with its built-in markup. createSlashMenuPlugin(options) builds one that renders your markup (see Custom markup).
What it lists
The menu lists edytor.commands:
- one command per block kind preset, such as
block.heading2“Heading 2” (see Blocks); - every command a plugin declares in
commands(see Writing plugins).
A command whose isEnabled(edytor) answers false is hidden. Block kind commands are enabled when the caret’s block is convertible, which excludes void blocks, island blocks and blocks inside them.
Commands are grouped into sections by their group, as in Notion: Basic blocks first, then the other groups in the order they first appear. Kind commands take their preset’s group (default Basic blocks; the image and code blocks are under Media), and a command without a group is listed without a heading. Each row shows the command’s hint on the right; kind commands use their first markdown prefix, such as ## for Heading 2.
The query matches, case-insensitively, the start of a word in a command’s label or keywords (hyphens are ignored, so /todo finds “To-do list”). /h2, /head and /subtitle all find “Heading 2”; /eading finds nothing. A query of several words keeps the menu open while each word, in order, starts a word of the same label or keyword, or continues the word the one before it started: /bullet list finds “Bulleted list”, /to do finds “To-do list”, and /list bullet finds nothing. The command id (block.heading2) is not searched. The block menu’s search matches with the same rule.
Keys
While the menu is open:
| Keys | Action |
|---|---|
| ↑, ↓ | Move the highlight (wraps around), across sections; typing moves it back to the first match |
| Enter | Run the highlighted command |
| Escape | Close the menu, keep the typed text |
| Hover | Highlight that command |
| Click | Run that command |
Enter is claimed only when at least one command matches. With no match it inserts a new block as usual.
Behavior
- The menu opens when
/is typed at a collapsed caret in a convertible block, at the start of a text or after whitespace, as in Notion. A/inside a word stays text:see 1/2,a/bandand/orfollowed by Enter keep their text and start a new block. An IME that commits/together with more text (/h) opens it with that query. - It closes when the caret leaves the query, the selection expands, the
/is deleted, or the query matches no command (a/in a URL or a path stays text, and ↑/↓ move the caret again). A space right after the/closes it too, and so does a query of only hyphens:yes / no,1 / 2andx /-followed by Enter keep their text and start a new block. “No results” shows only for a bare/when no command is enabled. It does not open in a readonly editor, and it closes when the editor turns readonly. - The query range is anchored in the document, so a collaborator’s edits elsewhere in the text do not break it.
- Running a command removes the
/querytext in the same step as the command, separate from the typing before it, so one undo restores both:hi /h2+ Enter undoes to a paragraphhi /h2. If the command is refused, the trigger text stays. - After a conversion the caret goes back to where the
/was. A command that moves the caret elsewhere keeps its own caret: the code block conversion puts it in the first line, and/dividerin a fresh block of the parent’s default kind after the divider (a divider renders no content, so it cannot hold the caret). - A kind that replaces the block’s content (
/divider,/code) converts the block in place only when it holds nothing but the query. In a block with other text or with children, the new block goes after it and the block stays intact:keep me /divider+ Enter leaveskeep meabove a divider. The query’s removal and the insertion are one step.
Placement and styling
The menu mounts in the editor’s overlay, in a position: fixed host with the attribute data-edytor-slash-menu-host and z-index: 50. It opens below the caret, or above it when there is no room, and stays inside the viewport while you scroll.
The menu’s markup and styles are built in, after Notion’s: sections with a heading, 28px rows with a line icon for the built-in kinds (a command’s text icon otherwise), the hint on the right, “No results” for a bare / when no command is enabled, and a “Close menu · esc” footer that closes it on click. It fades and scales in over 140ms, and the highlighted row scrolls into view. The typed query is not shown: it stays in a visually hidden element with data-testid="slash-menu-query" for screen readers and tests. Each item carries data-command-id, data-selected and data-hint.
Custom markup
createSlashMenuPlugin takes snippets that replace the menu’s markup. The plugin keeps everything else: opening on /, the query, the filtering, ↑/↓, Enter and Escape, and the placement.
item?Snippet<[SlashMenuItem]>
Replaces each row of the built-in menu. The sections, headings, empty state and footer stay.
Snippet<[SlashMenuItem]>menu?Snippet<[SlashMenuController]>
Replaces the whole menu. Rendered while controller.isOpen. Wins over item.
Snippet<[SlashMenuController]>An item snippet receives a SlashMenuItem:
command?EditorCommand
The command: id, label, icon, hint, group, keywords.
EditorCommandselected?boolean
It is the keyboard's row.
booleanicon?string | undefined
The built-in line icon as a CSS mask-image value, for the commands that have one.
string | undefinedrun?() => void
Run the command; the /query text is removed in the same step.
() => voidselect?() => void
Make it the keyboard's row, for hover.
() => voidA menu snippet receives the SlashMenuController. Read controller.commands (the filtered, enabled commands in menu order), controller.selectedIndex and controller.query; call controller.run(command) to run one and controller.close() to close the menu and keep the typed text.
<script lang="ts">
import { Edytor, createSlashMenuPlugin, type SlashMenuItem } from 'edytor';
const plugins = [createSlashMenuPlugin({ item })];
</script>
{#snippet item({ command, selected, run, select }: SlashMenuItem)}
<button
class="row"
class:selected
onmousedown={(event) => event.preventDefault()}
onmousemove={select}
onclick={run}
>
{command.icon} {command.label}
</button>
{/snippet}
<Edytor {plugins} />
Keep preventDefault on mousedown, so a click does not move the caret out of the query. Your markup renders in the same fixed host, so the built-in styles no longer apply. See Menus and handles for the other menus.