Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Menus and handles

Replace the markup of the slash menu, the selection toolbar, the block menu and the block handles with your own Svelte snippets, while the plugins keep the behavior.

The slash menu, the toolbar, the block menu and the block handles ship with Notion’s look. Each one also takes a snippet that renders your markup instead. The plugin keeps the behavior: when the UI opens, where it is placed, the keys it claims, and the commands it runs. Your snippet receives a controller (or a payload) to read state from and call actions on.

UI Factory Snippet Receives
Slash menu createSlashMenuPlugin item (one row) or menu (the whole menu) SlashMenuItem or SlashMenuController
Toolbar createToolbarPlugin toolbar ToolbarController
Block menu createBlockMenuPlugin menu BlockMenuController
Block handle createBlockHandlesPlugin, or the blockHandles prop handle BlockHandleSnippetPayload

All the factories and types are exported from edytor. slashMenuPlugin, toolbarPlugin, blockMenuPlugin and blockHandlesPlugin are the same plugins with the built-in markup.

Passing a snippet

A snippet declared at the top level of a component’s markup can be used in its <script>, so you can build the plugins there. This editor draws its own slash menu rows and block handles:

<script lang="ts">
	import {
		Edytor,
		createSlashMenuPlugin,
		type BlockHandleSnippetPayload,
		type SlashMenuItem
	} from 'edytor';

	const plugins = [createSlashMenuPlugin({ item })];
	const keep = (event: MouseEvent) => event.preventDefault();
</script>

{#snippet item({ command, selected, icon, run, select }: SlashMenuItem)}
	<button class="row" class:selected onmousedown={keep} onmousemove={select} onclick={run}>
		{#if icon}<span class="icon" style:mask-image={icon}></span>{:else}<span>{command.icon}</span>{/if}
		{command.label}
		{#if command.hint}<kbd>{command.hint}</kbd>{/if}
	</button>
{/snippet}

{#snippet handle({ block, grip, add, readonly }: BlockHandleSnippetPayload)}
	{#if !readonly}
		<button class="add" onmousedown={keep} onclick={(event) => add(event.altKey)}>+</button>
		<button class="grip" use:grip aria-label={`Move ${block.type} block`}>⠿</button>
	{/if}
{/snippet}

<Edytor {plugins} blockHandles={{ handle }} />

<style>
	.row {
		display: flex;
		gap: 8px;
		width: 100%;
	}
	.row.selected {
		background: #f1f1ef;
	}
	.icon {
		width: 18px;
		height: 18px;
		background: currentColor;
		mask-size: contain;
	}
</style>

The row’s icon is the built-in line icon as a CSS mask-image value, so a currentColor box masked with it draws the icon; commands without one fall back to their text command.icon. Your markup renders in the plugin’s host without the built-in styles.

Two rules apply to every snippet:

  • Keep the selection. Call preventDefault on mousedown in buttons that act on the text: slash rows, toolbar buttons, the +. Otherwise the click moves the caret before the action runs.
  • Mark the element to place. The toolbar measures [data-edytor-toolbar-bar] and the block menu [data-edytor-block-menu] when your markup has them, else the snippet’s first element.

Slash menu

item replaces each row and keeps the built-in sections, empty state and footer. menu replaces everything; read controller.commands, controller.selectedIndex and controller.query, and call controller.run(command) or controller.close(). The arrow keys, Enter and Escape stay the plugin’s either way. See Slash menu.

const slash = createSlashMenuPlugin({ menu }); // or { item }

Toolbar

toolbar renders while controller.isVisible, above the selection. The controller toggles marks (marks, toggleMark, isActive for a pressed state), converts the block (kinds, currentKind, turnInto), colors (setColor) and links (linkUrl, setLinkUrl, applyLink, removeLink), and tracks one open panel. See Toolbar.

const toolbar = createToolbarPlugin({ toolbar: bar });

Block menu

menu renders while controller.isOpen, beside the grip that opened it. The controller has the block, the blocks its actions apply to (the block selection when it holds block), its actions, the kinds it can turn into, and turnInto, duplicate, move, remove, copyLink and close. Each action, and close(), returns a text caret to the block (a block holding no text, such as a divider, stays selected): it replaces the block selection the grip click left. The built-in keys belong to the built-in search field, so a custom menu handles its own. See Block menu.

const blockMenu = createBlockMenuPlugin({ menu, linkTo: (block) => `#${block.id}` });

Block handle

handle replaces the + and the grip. use:grip gives any element the grip’s behavior: dragging, a click that selects the block and opens the block menu (or calls onActivate), and the Alt+arrow moves. add(above?) is what the + does. Placement beside the block’s first line and the hover fade stay the plugin’s. Pass it with blockHandles={{ handle }}, alongside draggable and onActivate, or with createBlockHandlesPlugin({ handle }). See Block handles.

Was this page helpful?