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 {
// Register capabilities here
}
destroy(): void {
// Clean up resources
}
onStateChange(oldState, newState, tr): void {
// React to state changes
}
onReady(): void {
// Called after ALL plugins are initialized
}
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;
},
});

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
priority: 50,
isActive: (state) => false, // Highlight when active
isDisabled: (state) => 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);
}, 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 (files, position) => {
for (const file of files) {
// Process each 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.');

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

if (context.isReadOnly()) {
return false; // Skip mutation in read-only mode
}
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',
priority: 47,
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.