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