Skip to content

Writing a Plugin

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
}
}

During init(), the context object provides everything your plugin needs:

// Read current state
const state = context.getState();
// Dispatch a transaction
const tr = state.transaction('command').insertText(blockId, offset, 'hello').build();
context.dispatch(tr);

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 command
context.executeCommand('toggleBold');

Register new node types and mark types:

// Register a new block type
context.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 mark
context.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 order
context.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.

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
},
});

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();
},
});

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
});

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.

Communicate between plugins:

import { EventKey } from '@notectl/core';
// Define a typed event
const MyEvent = new EventKey<{ value: string }>('my-event');
// Emit
context.getEventBus().emit(MyEvent, { value: 'hello' });
// Listen
const unsubscribe = context.getEventBus().on(MyEvent, (payload) => {
console.log(payload.value);
});

Expose typed services for other plugins:

import { ServiceKey } from '@notectl/core';
interface MyService {
doSomething(): void;
}
const MyServiceKey = new ServiceKey<MyService>('my-service');
// Register
context.registerService(MyServiceKey, {
doSomething() { /* ... */ },
});
// Consume (from another plugin)
const service = context.getService(MyServiceKey);
service?.doSomething();

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 first
// The content-editable element
const contentEl = context.getContainer();
// Plugin container areas (above/below the content)
const topArea = context.getPluginContainer('top');
const bottomArea = context.getPluginContainer('bottom');

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;
},
});

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
});

Inject CSS into the editor’s adopted stylesheets:

context.registerStyleSheet(`
.callout { padding: 12px; border-left: 4px solid blue; }
`);

Push announcements to screen readers via the aria-live region:

context.announce('Image resized to 400 by 300 pixels.');

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);
});

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
);

Update your plugin’s configuration at runtime (triggers re-initialization of affected features):

context.updateConfig({ maxWidth: 600 });

Check whether the editor is currently in read-only mode:

if (context.isReadOnly()) {
return false; // Skip mutation in read-only mode
}

Access the underlying registries for advanced use cases:

context.getSchemaRegistry(); // SchemaRegistry
context.getKeymapRegistry(); // KeymapRegistry
context.getInputRuleRegistry(); // InputRuleRegistry
context.getFileHandlerRegistry(); // FileHandlerRegistry
context.getNodeViewRegistry(); // NodeViewRegistry
context.getToolbarRegistry(); // ToolbarRegistry
context.getBlockTypePickerRegistry(); // BlockTypePickerRegistry

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');
}
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: '&#x1F58D;',
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()],
],
});

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.

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 logger or keep the default consoleLogger during development.
  • Do not use a thrown exception as control flow. Throwing to abort an editor operation does not work; return false from 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.