Writing a Plugin
Plugin Interface
Section titled “Plugin Interface”Every notectl plugin implements the Plugin interface:
import type { Plugin, PluginContext } from '@notectl/core';
class MyPlugin implements Plugin { readonly id = 'my-plugin'; readonly name = 'My Plugin'; readonly priority = 50; // Optional: controls init order readonly dependencies = []; // Optional: plugin IDs this depends on
init(context: PluginContext): void | Promise<void> { // Register capabilities here }
destroy(): void | Promise<void> { // Clean up resources }
onStateChange(oldState, newState, tr): void { // React to state changes }
onReady(): void | Promise<void> { // Called after ALL plugins are initialized }
onConfigure(config: TConfig): void { // Called when plugin config is updated at runtime via configurePlugin() }
onReadOnlyChange(readonly: boolean): void { // Called when the editor's read-only mode changes }
decorations(state: EditorState, tr?: Transaction): DecorationSet { // Return decorations for the current state }}PluginContext API
Section titled “PluginContext API”During init(), the context object provides everything your plugin needs:
State & Dispatch
Section titled “State & Dispatch”// Read current stateconst state = context.getState();
// Dispatch a transactionconst tr = state.transaction('command').insertText(blockId, offset, 'hello').build();context.dispatch(tr);Commands
Section titled “Commands”Register named commands that can be called from anywhere:
context.registerCommand('myCommand', () => { const state = context.getState(); // Do something... return true; // Return true if handled});
// Execute another plugin's commandcontext.executeCommand('toggleBold');Schema Extension
Section titled “Schema Extension”Register new node types and mark types:
// Register a new block typecontext.registerNodeSpec({ type: 'callout', content: 'inline*', group: 'block', attrs: { variant: { default: 'info' }, }, toDOM(node) { const div = document.createElement('div'); div.className = `callout callout--${node.attrs.variant}`; div.setAttribute('data-block-id', node.id); return div; },});
// Register a new inline markcontext.registerMarkSpec({ type: 'highlight', rank: 7, attrs: { color: { default: 'yellow' }, }, toDOM(mark) { const span = document.createElement('span'); span.style.backgroundColor = mark.attrs.color; return span; },});To augment a block type another plugin owns, declare a NodeSpecExtension instead of
re-registering its spec. Extensions are materialized after every plugin has registered, so the
target plugin may initialize later than yours:
// Allow our callout inside table cells, regardless of plugin ordercontext.registerNodeSpecExtension('table_cell', (cellSpec) => { if (!cellSpec.content || cellSpec.content.allow.includes('callout')) return cellSpec; return { ...cellSpec, content: { ...cellSpec.content, allow: [...cellSpec.content.allow, 'callout'] }, };});Return spec unchanged when your change is already applied; the schema can be rebuilt and the
extension re-run. See Extending Another Plugin’s NodeSpec.
Keymaps
Section titled “Keymaps”Bind keyboard shortcuts:
context.registerKeymap({ 'Mod-Shift-h': () => { context.executeCommand('myCommand'); return true; }, 'Mod-Enter': () => { // Mod = Cmd on Mac, Ctrl on Windows/Linux return false; // Return false to let other handlers try },});Input Rules
Section titled “Input Rules”Transform text patterns as the user types:
context.registerInputRule({ // Match "---" at the start of a line pattern: /^---$/, handler: (state, match, blockId) => { // Replace with horizontal rule return state.transaction('input') .setBlockType(blockId, nodeType('horizontal_rule')) .build(); },});Toolbar Items
Section titled “Toolbar Items”Add buttons to the toolbar:
context.registerToolbarItem({ id: 'my-button', group: 'format', icon: '<svg>...</svg>', // HTML string for the icon label: 'My Action', // Accessible label tooltip: 'Do something', command: 'myCommand', // Command to execute on click isActive: (state) => false, // Highlight when active isEnabled: (state) => true, // Disable when returns false});Block Type Picker
Section titled “Block Type Picker”Add custom entries to the block type dropdown (the “Paragraph / Heading / Title” picker provided by HeadingPlugin). Your plugin must declare dependencies: ['heading'] so the picker exists when entries are registered.
context.registerBlockTypePickerEntry({ id: 'footer', label: 'Footer', command: 'setFooter', // Must be a registered command priority: 200, // Higher = further down the list style: { // Optional: preview styling in the dropdown fontSize: '0.85em', fontWeight: '400', }, isActive: (state) => { const block = state.getBlock(state.selection.anchor.blockId); return block?.type === 'footer'; },});Built-in entries use priorities 10–106 (paragraph=10, title=20, subtitle=30, headings=101–106). Use 200+ to place entries after the built-in block types.
Event Bus
Section titled “Event Bus”Communicate between plugins:
import { EventKey } from '@notectl/core';
// Define a typed eventconst MyEvent = new EventKey<{ value: string }>('my-event');
// Emitcontext.getEventBus().emit(MyEvent, { value: 'hello' });
// Listenconst unsubscribe = context.getEventBus().on(MyEvent, (payload) => { console.log(payload.value);});Services
Section titled “Services”Expose typed services for other plugins:
import { ServiceKey } from '@notectl/core';
interface MyService { doSomething(): void;}
const MyServiceKey = new ServiceKey<MyService>('my-service');
// Registercontext.registerService(MyServiceKey, { doSomething() { /* ... */ },});
// Consume (from another plugin)const service = context.getService(MyServiceKey);service?.doSomething();Middleware
Section titled “Middleware”Intercept transactions before they’re applied:
context.registerMiddleware((tr, state, next) => { // Inspect the transaction console.log('Transaction steps:', tr.steps.length);
// Optionally modify or cancel if (shouldCancel(tr)) { return; // Don't call next() to cancel }
// Pass through next(tr);}, { priority: 100 }); // Priority: lower = runs firstDOM Access
Section titled “DOM Access”// The content-editable elementconst contentEl = context.getContainer();
// Plugin container areas (above/below the content)const topArea = context.getPluginContainer('top');const bottomArea = context.getPluginContainer('bottom');Inline Node Specs
Section titled “Inline Node Specs”Register atomic inline elements (like hard breaks, emoji, or mentions):
context.registerInlineNodeSpec({ type: 'emoji', attrs: { code: { default: '' }, }, toDOM(node) { const span = document.createElement('span'); span.textContent = node.attrs.code; span.setAttribute('contenteditable', 'false'); return span; },});File Handlers
Section titled “File Handlers”Register handlers for drag-and-drop or paste of files:
context.registerFileHandler('image/*', async (file, position) => { // Process the matched file return true; // Return true if handled});Style Sheets
Section titled “Style Sheets”Inject CSS into the editor’s adopted stylesheets:
context.registerStyleSheet(` .callout { padding: 12px; border-left: 4px solid blue; }`);Accessibility Announcements
Section titled “Accessibility Announcements”Push announcements to screen readers via the aria-live region:
context.announce('Image resized to 400 by 300 pixels.');Node Views
Section titled “Node Views”Register a custom node view factory for a block type:
context.registerNodeView('image', (node, getState, dispatch) => { // Return a NodeView implementation for custom rendering return new ImageNodeView(node, getState, dispatch);});Paste Interceptors
Section titled “Paste Interceptors”Register a paste interceptor that can transform pasted content before it is applied to the editor:
context.registerPasteInterceptor( { intercept(data, state) { // Transform or replace pasted content // Return a Transaction to override default paste, or undefined to pass through return undefined; }, }, { priority: 100 }, // Lower = runs first);Runtime Config Updates
Section titled “Runtime Config Updates”Update your plugin’s configuration at runtime (triggers re-initialization of affected features):
context.updateConfig({ maxWidth: 600 });Read-Only State
Section titled “Read-Only State”Check whether the editor is currently in read-only mode:
if (context.isReadOnly()) { return false; // Skip mutation in read-only mode}Registry Access
Section titled “Registry Access”Access the underlying registries for advanced use cases:
context.getSchemaRegistry(); // SchemaRegistrycontext.getKeymapRegistry(); // KeymapRegistrycontext.getInputRuleRegistry(); // InputRuleRegistrycontext.getFileHandlerRegistry(); // FileHandlerRegistrycontext.getNodeViewRegistry(); // NodeViewRegistrycontext.getToolbarRegistry(); // ToolbarRegistrycontext.getBlockTypePickerRegistry(); // BlockTypePickerRegistryAnnouncement Check
Section titled “Announcement Check”Check if another plugin has already made an announcement (to avoid duplicate screen reader messages):
if (!context.hasAnnouncement()) { context.announce('Moved to heading level 2');}Complete Example: Highlight Plugin
Section titled “Complete Example: Highlight Plugin”import type { Plugin, PluginContext } from '@notectl/core';import { markType, isMarkActive, toggleMark } from '@notectl/core';
class HighlightPlugin implements Plugin { readonly id = 'highlight'; readonly name = 'Highlight'; readonly priority = 47;
init(context: PluginContext): void { // Register mark context.registerMarkSpec({ type: 'highlight', rank: 7, attrs: { color: { default: 'yellow' }, }, toDOM(mark) { const span = document.createElement('span'); span.style.backgroundColor = mark.attrs?.color ?? 'yellow'; return span; }, });
// Register command context.registerCommand('toggleHighlight', () => { const state = context.getState(); const tr = toggleMark(state, markType('highlight')); if (tr) { context.dispatch(tr); return true; } return false; });
// Register keymap context.registerKeymap({ 'Mod-Shift-h': () => context.executeCommand('toggleHighlight'), });
// Register toolbar item context.registerToolbarItem({ id: 'highlight', group: 'format', icon: '🖍', label: 'Highlight', tooltip: 'Highlight (Cmd+Shift+H)', command: 'toggleHighlight', isActive: (state) => isMarkActive(state, markType('highlight')), }); }}
export { HighlightPlugin };Usage:
const editor = await createEditor({ toolbar: [ [new TextFormattingPlugin()], [new HighlightPlugin()], ],});TypeScript Attribute Registry
Section titled “TypeScript Attribute Registry”For type-safe mark attributes, augment the MarkAttrRegistry:
declare module '@notectl/core' { interface MarkAttrRegistry { highlight: { color: string }; }}This enables type checking when you use isMarkOfType(mark, 'highlight') — the compiler knows mark.attrs.color exists.
How Failures in Your Plugin Behave
Section titled “How Failures in Your Plugin Behave”Every callback you register runs behind an error boundary. If it throws or returns a rejected
promise, the editor does not break: it continues to the next matching handler, or renders an
attributed DOM fallback, and reports the failure through the editor’s
logger together with your plugin id and the callback name. The full
matrix is in the PluginContext reference.
This matters while developing a plugin:
- Your exceptions do not appear as uncaught errors. If a feature quietly does nothing or falls
back to default rendering, read the logger output before suspecting the editor. Pass a custom
loggeror keep the defaultconsoleLoggerduring development. - Do not use a thrown exception as control flow. Throwing to abort an editor operation does not
work; return
falsefrom a command, input rule, keymap, or file handler to decline it instead. - Keep callbacks free of partial side effects. The editor continues past a failure, so a callback that mutated external state halfway leaves that state inconsistent with the document.
Lifecycle failures behave the same way. If your init() throws, the whole initialization is rolled
back and every registration made by every plugin is removed, so a retry starts from an empty
registry rather than a half-built schema.