Skip to content

Plugin Interface

interface Plugin<TConfig extends Record<string, unknown> = Record<string, unknown>> {
/** Unique identifier. */
readonly id: string;
/** Human-readable name. */
readonly name: string;
/** Plugin initialization order priority (lower = first). */
readonly priority?: number;
/** IDs of plugins that must be registered before this one. */
readonly dependencies?: readonly string[];
/** Called during initialization. Register specs, commands, keymaps, etc. */
init(context: PluginContext): void | Promise<void>;
/** Clean up resources. */
destroy?(): void | Promise<void>;
/** Called on every state change. */
onStateChange?(oldState: EditorState, newState: EditorState, tr: Transaction): void;
/** Called when configurePlugin() is used at runtime. */
onConfigure?(config: TConfig): void;
/** Called after ALL plugins are initialized. */
onReady?(): void | Promise<void>;
/** Called when the editor's read-only mode changes. */
onReadOnlyChange?(readonly: boolean): void;
/** Returns decorations for the current state. */
decorations?(state: EditorState, tr?: Transaction): DecorationSet;
}

The PluginContext is passed to init() and provides all registration APIs:

interface PluginContext {
// --- State ---
getState(): EditorState;
dispatch(transaction: Transaction): void;
// --- DOM ---
getContainer(): HTMLElement;
getPluginContainer(position: 'top' | 'bottom'): HTMLElement;
// --- Commands ---
registerCommand(name: string, handler: CommandHandler, options?: CommandOptions): void;
executeCommand(name: string): boolean;
// --- Schema ---
registerNodeSpec<T extends string>(spec: NodeSpec<T>): void;
registerMarkSpec<T extends string>(spec: MarkSpec<T>): void;
registerInlineNodeSpec<T extends string>(spec: InlineNodeSpec<T>): void;
registerNodeView(type: string, factory: NodeViewFactory): void;
getSchemaRegistry(): SchemaRegistry;
// --- Input ---
registerKeymap(keymap: Keymap, options?: KeymapOptions): void;
registerInputRule(rule: InputRule): void;
registerFileHandler(pattern: string, handler: FileHandler): void;
registerPasteInterceptor(interceptor: PasteInterceptor, options?: PasteInterceptorOptions): void;
// --- Accessibility ---
announce(text: string): void;
hasAnnouncement(): boolean;
// --- Read-Only ---
isReadOnly(): boolean;
// --- Styling ---
registerStyleSheet(css: string): void;
// --- Toolbar ---
registerToolbarItem(item: ToolbarItem): void;
registerBlockTypePickerEntry(entry: BlockTypePickerEntry): void;
// --- Middleware ---
registerMiddleware(middleware: TransactionMiddleware, options?: MiddlewareOptions): void;
// --- Services ---
registerService<T>(key: ServiceKey<T>, service: T): void;
getService<T>(key: ServiceKey<T>): T | undefined;
// --- Events ---
getEventBus(): PluginEventBus;
// --- Registry Access ---
getKeymapRegistry(): KeymapRegistry;
getInputRuleRegistry(): InputRuleRegistry;
getFileHandlerRegistry(): FileHandlerRegistry;
getNodeViewRegistry(): NodeViewRegistry;
getToolbarRegistry(): ToolbarRegistry;
getBlockTypePickerRegistry(): BlockTypePickerRegistry;
// --- Config ---
updateConfig(config: PluginConfig): void;
}

Plugins that need to show popups (dropdowns, color pickers, dialogs) should use the shared Popup Framework via PopupServiceKey rather than managing DOM elements directly.

import { EventKey } from '@notectl/core';
const MyEvent = new EventKey<{ value: string }>('my-event');
bus.emit(MyEvent, { value: 'hello' }); // Type-checked
const unsubscribe = bus.on(MyEvent, (payload) => {
payload.value; // string — type-safe
});
unsubscribe(); // Remove the listener
import { ServiceKey } from '@notectl/core';
interface MyService { doWork(): void; }
const MyKey = new ServiceKey<MyService>('my-service');
context.registerService(MyKey, { doWork() { /* ... */ } });
const svc = context.getService(MyKey); // MyService | undefined
type PasteInterceptor = (
plainText: string,
html: string,
state: EditorState,
) => Transaction | null;

A paste interceptor receives the clipboard contents and current state. Return a Transaction to handle the paste, or null to let the next interceptor try.

interface PasteInterceptorOptions {
readonly name?: string;
readonly priority?: number; // Lower values run first. Default: 100
}
type CommandHandler = () => boolean;

Return true if the command was handled, false to let other handlers try.

interface CommandOptions {
/** When true, the command may execute even in read-only mode. */
readonly readonlyAllowed?: boolean;
}

Used with registerCommand() to allow specific commands (e.g. checklist toggle) to work in read-only mode.

interface KeymapOptions {
/** Priority level for dispatch ordering. */
readonly priority?: KeymapPriority;
}

See Input System — Priority System for details on KeymapPriority.

interface MiddlewareOptions {
/** Human-readable name for debugging and introspection. */
readonly name?: string;
/** Execution priority (lower values run first). Defaults to 100. */
readonly priority?: number;
}
type TransactionMiddleware = (
tr: Transaction,
state: EditorState,
next: MiddlewareNext,
) => void;

Call next(tr) to continue the chain. Skip next() to cancel the transaction.

Entries registered via registerBlockTypePickerEntry() appear in the HeadingPlugin’s block type dropdown.

interface BlockTypePickerEntry {
/** Unique identifier, e.g. 'heading-1', 'footer'. */
readonly id: string;
/** Display label shown in the picker, e.g. 'Heading 1'. */
readonly label: string;
/** Command to execute when selected. */
readonly command: string;
/** Sort order — lower values appear first. */
readonly priority: number;
/** Optional styling for the label in the dropdown. */
readonly style?: PickerEntryStyle;
/** Returns true when this entry matches the current block type. */
isActive(state: EditorState): boolean;
}
interface PickerEntryStyle {
readonly fontSize: string;
readonly fontWeight: string;
}

The HeadingPlugin registers its built-in entries at priorities 10–106 (paragraph=10, title=20, subtitle=30, headings=101–106). Use a higher priority value (e.g. 200+) to append entries after the built-in ones.


The PluginManager orchestrates plugin lifecycle, registration, and dispatch. It is primarily used internally by the editor, but its API is exported for advanced use cases.

import { PluginManager } from '@notectl/core';
interface PluginManagerInitOptions {
getState(): EditorState;
dispatch(transaction: Transaction): void;
getContainer(): HTMLElement;
getPluginContainer(position: 'top' | 'bottom'): HTMLElement;
announce?(text: string): void;
hasAnnouncement?(): boolean;
onBeforeReady?(): void | Promise<void>;
}
MethodSignatureDescription
register(plugin: Plugin) => voidRegister a plugin (must be called before init)
init(options: PluginManagerInitOptions) => Promise<void>Initialize all plugins in dependency/priority order
destroy() => Promise<void>Destroy all plugins in reverse init order
notifyStateChange(oldState, newState, tr) => voidNotify all plugins of a state change
collectDecorations(state, tr?) => DecorationSetCollect and merge decorations from all plugins
dispatchWithMiddleware(tr, state, finalDispatch) => voidDispatch through the middleware chain
canExecuteCommand(name: string) => booleanCheck if a command exists and is not blocked by readonly
executeCommand(name: string) => booleanExecute a named command
configurePlugin(pluginId, config) => voidConfigure a plugin at runtime
isReadOnly() => booleanGet current readonly state
setReadOnly(readonly: boolean) => voidUpdate readonly state and notify plugins
getPluginIds() => string[]List all registered plugin IDs
get(id: string) => Plugin | undefinedGet a plugin by ID
getService<T>(key: ServiceKey<T>) => T | undefinedGet a registered service
onEvent<T>(key: EventKey<T>, cb) => () => voidSubscribe to an event (returns unsubscribe)
getMiddlewareChain() => readonly MiddlewareInfo[]Get middleware in execution order
getPluginStyleSheets() => readonly CSSStyleSheet[]Get all plugin-registered stylesheets

The PluginManager exposes its internal registries as readonly properties:

manager.schemaRegistry; // SchemaRegistry
manager.keymapRegistry; // KeymapRegistry
manager.inputRuleRegistry; // InputRuleRegistry
manager.fileHandlerRegistry; // FileHandlerRegistry
manager.nodeViewRegistry; // NodeViewRegistry
manager.toolbarRegistry; // ToolbarRegistry
manager.blockTypePickerRegistry; // BlockTypePickerRegistry

Describes a registered middleware entry (returned by getMiddlewareChain()):

interface MiddlewareInfo {
readonly name: string;
readonly priority: number;
readonly pluginId: string;
}

Type-safe event bus used for inter-plugin communication. Plugins access it via context.getEventBus().

import { EventBus, EventKey } from '@notectl/core';
const bus = new EventBus();
MethodSignatureDescription
emit<T>(key: EventKey<T>, payload: T) => voidEmit an event to all subscribers
on<T>(key: EventKey<T>, callback) => () => voidSubscribe to an event (returns unsubscribe function)
off<T>(key: EventKey<T>, callback) => voidRemove a specific listener
clear() => voidRemove all listeners

If a subscriber throws, the error is caught and logged — other subscribers still receive the event. This prevents a buggy plugin from breaking the event system.