Example plugin
A complete plugin in one Svelte file, with a note block kind that has data and chrome, a keyboard mark, a hotkey, presets and an operation hook.
This page builds a note plugin from start to finish. It adds:
- a
noteblock kind with atonein its data (infoorwarning), a clickable icon to switch it, and nested children; - a
keyboardmark that renders as<kbd>and gets a toolbar button; - a Mod + Shift + K hotkey that toggles the mark;
- presets, so the slash menu, markdown shortcuts and block menus can create notes;
- an operation hook: Enter in an empty note turns it back into a paragraph.
It uses the snippet types from Typing snippets.
The plugin file
<script module lang="ts">
import type { Block, Plugin } from 'edytor';
import type { BlockSnippetPayload, EdytorInstance } from 'edytor';
import './note.css';
const toneOf = (data: Record<string, unknown>) => (data.tone === 'warning' ? 'warning' : 'info');
/** Switch a note between info and warning. */
const cycleTone = (note: Block) => {
const tone = toneOf(note.data) === 'info' ? 'warning' : 'info';
note.setBlock({ value: { data: { ...note.data, tone } } });
};
/** Toggle the keyboard mark over the selection, or stage it for the next character typed. */
const toggleKeyboard = (edytor: EdytorInstance) => {
const { texts, startText, endText, yStart, yEnd, isCollapsed, isReversed } =
edytor.selection.state;
if (!startText || !endText) return;
if (isCollapsed) {
startText.markText({ mark: 'keyboard', toggle: true, start: yStart, end: yStart });
return;
}
texts.forEach((text, index) =>
text.markText({
mark: 'keyboard',
toggle: true,
start: index === 0 ? yStart : 0,
end: index === texts.length - 1 ? yEnd : text.length
})
);
edytor.selection.setAtRange(startText, yStart, endText, yEnd, { isReversed });
};
export const notePlugin: Plugin = (edytor) => ({
blocks: {
note: {
snippet: note,
// The core renders <aside data-tone="…">; the snippet renders inside it.
element: (data) => ({ tag: 'aside', attributes: { 'data-tone': toneOf(data) } }),
presets: [
{
label: 'Note',
icon: 'ℹ',
keywords: ['info', 'aside'],
data: { tone: 'info' },
markdown: ['!! ']
},
{ label: 'Warning', icon: '⚠', keywords: ['caution'], data: { tone: 'warning' } }
],
// Clipboard: export as <aside data-tone>, and read it back on paste.
html: (block, content, children) =>
`<aside data-tone="${toneOf(block.data ?? {})}">${content}${children}</aside>`,
parse: (element) =>
element.localName === 'aside'
? { tone: element.dataset.tone === 'warning' ? 'warning' : 'info' }
: undefined
}
},
marks: {
keyboard: { tag: 'kbd', toolbar: { label: 'Keyboard', icon: '⌨' } }
},
hotkeys: {
'mod+shift+k': ({ prevent }) => prevent(() => toggleKeyboard(edytor))
},
onBeforeOperation: ({ operation, block, prevent }) => {
// Enter in an empty note inserts a block after it. Leave the note instead.
if (operation === 'insertBlockAfter' && block.type === 'note' && block.isEmpty) {
prevent(() => {
block.setBlock({ value: { type: 'paragraph', data: {} } });
});
}
}
});
</script>
{#snippet note({ block, content, children }: BlockSnippetPayload)}
<button
type="button"
class="note-icon"
use:block.void
aria-label="Switch note tone"
onclick={() => cycleTone(block.handle)}
>
{toneOf(block.data) === 'warning' ? '⚠' : 'ℹ'}
</button>
<div class="note-body">{@render content()}</div>
{#if children}
<div class="note-children">{@render children()}</div>
{/if}
{/snippet}
A few things to note:
- The snippet renders only the inside of the block. The core renders the
<aside>fromelement, with the editor’s data attributes on it. use:block.voidmarks the icon button as non-editable chrome, so the caret never lands in it. The button is a native control, so its click is its own.block.datain the snippet is reactive: switching the tone re-renders the icon and, throughelement, thedata-toneattribute. Commands go throughblock.handle, the block’s handle.setBlockreplacesdataas a whole, socycleTonespreads the current data.- The note has two presets, so its commands are
block.note1andblock.note2. Typing!!at the start of an empty block converts it, with the markdown shortcuts plugin. - The hook refuses
insertBlockAfteronly for an empty note. Other edits pass through. - The paragraph conversion assumes the rich text plugin defines
paragraph.
The styles
Style the block through its stable attributes. Put nested blocks in a separate grid row so they start below the note’s text:
[data-edytor-type='note'] {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
column-gap: 10px;
margin: 12px 0;
padding: 12px 14px;
border-radius: 6px;
background: #eef4ff;
}
[data-edytor-type='note'][data-tone='warning'] {
background: #fff4e5;
}
[data-edytor-type='note'] > .note-body,
[data-edytor-type='note'] > .note-children {
grid-column: 2;
min-width: 0;
}
.note-icon {
cursor: pointer;
}
kbd[data-edytor-mark='keyboard'] {
padding: 1px 5px;
border: 1px solid #d4d4d4;
border-radius: 4px;
font-size: 0.85em;
}
Using it
Pass it with the bundled plugins (<Edytor> adds rich text after them). None of its names overlap theirs, so order does not matter here; listing your own plugins first lets them override bundled definitions later.
<script lang="ts">
import { Edytor, slashMenuPlugin, toolbarPlugin, markdownShortcutsPlugin } from 'edytor';
import { notePlugin } from '$lib/NotePlugin.svelte';
const plugins = [notePlugin, markdownShortcutsPlugin, slashMenuPlugin, toolbarPlugin];
const value = {
children: [
{
type: 'note',
data: { tone: 'warning' },
content: [{ text: 'Press ' }, { text: 'Mod+S', marks: { keyboard: true } }, { text: ' to save.' }]
}
]
};
</script>
<Edytor {plugins} {value} />
Type /note to insert a note from the slash menu, select text and press Mod + Shift + K or the toolbar’s ⌨ button to mark it, and click the icon to switch the tone.