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

Writing plugins

The full plugin contract, every definition field and hook with its payload, and how to type snippets in your own plugins.

This page lists everything a plugin can return and when each hook runs. The operation hooks, which see and can veto every edit, have their own page: Operations. For a complete plugin built from these pieces, see Example plugin.

The contract

import type { Plugin } from 'edytor';

export const myPlugin: Plugin = (edytor) => {
	// Runs once, when the editor is created. Keep plugin state here.
	return {
		blocks: {},
		marks: {},
		inlineBlocks: {},
		hotkeys: {},
		commands: [],
		onBeforeOperation: (change) => {},
		onChange: (value) => {}
	};
};

Definitions

PropType
blocks?Record<string, BlockDefinition | Snippet>

Block kinds by type name. A bare snippet is shorthand for { snippet }. See Blocks.

TypeRecord<string, BlockDefinition | Snippet>
marks?Record<string, MarkDefinition | Snippet>

Text marks by name. A bare snippet is shorthand for { snippet }. See Marks.

TypeRecord<string, MarkDefinition | Snippet>
inlineBlocks?Record<string, InlineBlockDefinition | Snippet>

Inline atoms by type name. See Inline blocks.

TypeRecord<string, InlineBlockDefinition | Snippet>
hotkeys?Partial<Record<HotKeyCombination, HotKey>>

Key bindings such as 'mod+shift+k'. See Hotkeys.

TypePartial<Record<HotKeyCombination, HotKey>>
commands?EditorCommand[]

Named commands. The slash menu lists them next to the block kinds.

TypeEditorCommand[]
placeholder?string | ((view) => string | null)

Text shown in empty blocks. The <Edytor> prop wins over a plugin's. See Placeholder.

Typestring | ((view) => string | null)

Hooks

PropType
onBeforeOperation?(change) => payload | void

Before every edit, on the command and each step it plans. Can veto, replace or rewrite it.

Type(change) => payload | void
onAfterOperation?(change) => void

Once per command, after it was written.

Type(change) => void
onChange?(value: JSONBlock) => void

After every commit that changed the visible document.

Type(value: JSONBlock) => void
onSelectionChange?(selection: EdytorSelection) => void

After the selection value changed.

Type(selection: EdytorSelection) => void
onEdytorAttached?({ node }) => (() => void) | void

When the editor element mounts. May return a cleanup.

Type({ node }) => (() => void) | void
onBlockAttached?({ node, block }) => (() => void) | void

When a block element mounts. May return a cleanup.

Type({ node, block }) => (() => void) | void
onTextAttached?({ node, text }) => (() => void) | void

When a text segment element mounts. May return a cleanup.

Type({ node, text }) => (() => void) | void
onBeforeInput?({ e, prevent }) => void

Before the editor performs a beforeinput it handles itself.

Type({ e, prevent }) => void
onCopy?({ e, prevent }) => void

Before the editor writes the clipboard on copy.

Type({ e, prevent }) => void
onCut?({ e, prevent }) => void

Before the editor writes the clipboard and deletes on cut.

Type({ e, prevent }) => void
onPaste?({ e, prevent }) => void

Before the editor imports external clipboard data or dropped files.

Type({ e, prevent }) => void
onDeleteSelectedBlocks?({ selectedBlocks, prevent }) => void

Before Backspace or Delete removes a block selection.

Type({ selectedBlocks, prevent }) => void

The prevent function

Every hook whose payload has prevent can stop what the editor was about to do. Hotkeys use the same function:

  • prevent() stops the default behavior. Nothing is written.
  • prevent(() => { ... }) stops it and runs your callback in its place.

prevent works by throwing, so code after it in the same hook does not run. The first plugin in list order that calls it wins; later plugins are not asked. The callback runs as a command of its own: if one of its edits is refused by another plugin, that edit is skipped and the callback continues.

Lifecycle hooks

onEdytorAttached, onBlockAttached and onTextAttached run when the element mounts and may return a cleanup function, called when it unmounts. Anything else they return (nothing, a promise) is ignored. Use them to attach listeners or third-party libraries to the DOM.

import type { Plugin } from 'edytor';

export const anchorsPlugin: Plugin = () => ({
	// Give every block element an id so it can be linked to.
	onBlockAttached: ({ node, block }) => {
		node.id = `block-${block.id}`;
		return () => node.removeAttribute('id');
	}
});
  • onEdytorAttached({ node }) receives the contenteditable root. The overlay layer for your own chrome exists at this point as edytor.overlay.layer.
  • onBlockAttached({ node, block }) runs once per block element. An element is re-created when its tag changes or the block moves, and the hook runs again for the new one.
  • onTextAttached({ node, text }) runs for each text segment element, the span that holds a run of text between inline atoms.

Attributes you add to a block element are left alone. Do not add or remove nodes inside the text elements: the editor restores the DOM it owns. See Styling.

Change and selection hooks

import type { Plugin } from 'edytor';

export const statsPlugin: Plugin = () => ({
	onChange: (value) => {
		// value is the whole document: { type: 'root', children: [...] }
		localStorage.setItem('draft', JSON.stringify(value.children));
	},
	onSelectionChange: (selection) => {
		console.log(selection.value.kind); // 'none' | 'text' | 'atom' | 'blocks'
	}
});
  • onChange(value) runs after every commit that changed the visible document: local edits, remote edits and undo or redo. value is the full JSON export, computed only when an onChange consumer exists. It is the same value the <Edytor onChange> prop receives.
  • onSelectionChange(selection) runs after the selection value changed. A remote edit that only shifts where the selection is drawn does not trigger it. selection is the editor’s EdytorSelection: selection.value is the value, selection.state the resolved texts and offsets.

Input and clipboard hooks

These hooks receive the DOM event as e and a prevent function.

onBeforeInput runs for the beforeinput events the editor performs itself instead of letting the browser apply them: Enter and Shift + Enter, deletions that are not a simple edit inside one text, the formatting input types (formatBold and the others that browser menus and iOS send), insertLink, insertOrderedList, insertUnorderedList and insertHorizontalRule. It runs after the key bindings and before the editor’s own handling. The event’s default is already prevented, so prevent() means nothing happens unless your callback does something. Plain typing that the browser performs is not seen here: it reaches onBeforeOperation as an insertText operation. Paste and drop do not reach this hook; use onPaste.

onCopy and onCut run before the editor writes its clipboard data. prevent() stops the editor from writing anything and prevents the event’s default; write your own data in the callback with e.clipboardData.setData(...). A prevented cut also deletes nothing. Cut does not run in a readonly editor.

onPaste runs after the editor checked for its own fragment format (content copied from an Edytor pastes without asking plugins), and before it imports external HTML or plain text. Pasted files reach the editor only through this hook: unclaimed files insert nothing. The hook also runs for dropped files and HTML, with an e that carries only clipboardData. A Shift-paste of plain text skips it.

import type { Plugin } from 'edytor';

export const noFilesPlugin: Plugin = () => ({
	onPaste: ({ e, prevent }) => {
		if (e.clipboardData?.files.length) {
			prevent(() => alert('Upload files with the media button.'));
		}
	}
});

onDeleteSelectedBlocks runs when Backspace or Delete removes a block selection, with the blocks in document order. prevent() keeps them.

For the clipboard formats and paste rules, see Clipboard.

Commands

commands adds named actions that menus can list and run. The slash menu shows every command next to the block kinds, sectioned by group.

import type { Plugin } from 'edytor';

export const todoCommandsPlugin: Plugin = () => ({
	commands: [
		{
			id: 'todo.toggle',
			label: 'Toggle to-do',
			icon: '☑',
			keywords: ['check', 'done'],
			isEnabled: (edytor) => edytor.selection.state.startBlock?.type === 'todo-item',
			run: (edytor) => {
				const block = edytor.selection.state.startBlock;
				block?.setBlock({ value: { data: { ...block.data, checked: !block.data.checked } } });
			}
		}
	]
});
PropType
idstring

Unique id. The first plugin to register an id wins.

Typestring
labelstring

Shown in menus.

Typestring
icon?string

A short text icon.

Typestring
keywords?string[]

Extra search terms.

Typestring[]
group?string

The menu section. The slash menu lists 'Basic blocks' first, then groups in first-seen order; a command without one gets no heading.

Typestring
hint?string

A shortcut shown right-aligned in the slash menu row, such as '##' or '⌘D'.

Typestring
isEnabled?(edytor) => boolean

Hide or disable the command.

Type(edytor) => boolean
run(edytor) => unknown

The action. May be async.

Type(edytor) => unknown

Run a command by id with edytor.runCommand(id). Block kinds with presets generate commands too, see Blocks. See also Commands.

Typing snippets

The package exports the payload and instance types:

import type {
	BlockSnippetPayload,
	MarkSnippetPayload,
	InlineBlockSnippetPayload,
	EdytorInstance // the editor a plugin, hotkey or `bind:edytor` receives
} from 'edytor';

The UI plugins’ snippets have their own types (SlashMenuItem, SlashMenuController, ToolbarController, BlockMenuController, BlockHandleSnippetPayload); see Menus and handles.

Was this page helpful?