Creating Plugins
Extending the Editor by writing your first Extension.
Every Scrivr feature — built-in or custom — is an Extension. Extensions declare schema
contributions, keymaps, commands, layout handlers, and lifecycle hooks through a single
Extension.create() call.
Extension.create()
import { Extension } from '@scrivr/core';
const MyExtension = Extension.create<MyOptions>({
name: 'myExtension',
defaultOptions: {
myOption: 'default',
},
// ...config hooks
});The generic type parameter <MyOptions> makes your options typesafe. defaultOptions provides
fallback values — callers can override with .configure({ myOption: 'custom' }).
Configuration hooks
addKeymap
Returns a map of keyboard shortcuts to ProseMirror commands.
addKeymap() {
return {
'Mod-Shift-k': (state, dispatch) => {
if (dispatch) {
dispatch(state.tr.insertText('→'));
}
return true;
},
};
},Bindings for the same key chain rather than override. Returning false means "not
applicable here" and delegates to the next binding, which is how one key carries several
meanings — Tab navigates cells inside a table, indents inside a code block, and sinks a list
item inside a list.
keymapPriority
Decides which binding gets first refusal for a key. Higher runs first; the default is
KeymapPriority.default (100), and equal priorities fall back to registration order.
import { Extension, KeymapPriority } from '@scrivr/core';
Extension.create({
name: 'myTableThing',
keymapPriority: KeymapPriority.table, // 400 — only applies inside a table
addKeymap() {
return { Tab: myCellCommand };
},
});Give context-specific handlers a higher priority than general fallbacks. The built-in ladder is
table (400) → codeBlock (300) → list (200) → default (100); slot your own in relative to
those (e.g. KeymapPriority.list + 10).
This is deliberately independent of where an extension sits in the extensions array: that
order already decides the schema's default block type, and one ordering cannot serve both.
addCommands
Contributes commands to editor.commands. Each command factory receives the current
state and returns a ProseMirror command function.
addCommands() {
return {
insertArrow: () => (state, dispatch) => {
if (dispatch) {
dispatch(state.tr.insertText('→'));
}
return true;
},
};
},After registration: editor.commands.insertArrow().
addProseMirrorPlugins
Contributes low-level ProseMirror plugins — plugin state, and transactions derived from other transactions.
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('myPlugin'),
state: {
init: () => ({ count: 0 }),
apply: (tr, value) => (tr.docChanged ? { count: value.count + 1 } : value),
},
// Enforce a document invariant after every change.
appendTransaction(transactions, oldState, newState) {
if (!transactions.some((tr) => tr.docChanged)) return null;
// ...return a transaction, or null to decline
return null;
},
}),
];
},Plugin.spec.view() and plugin props never run. They are prosemirror-view hooks, and
Scrivr paints to canvas — there is no EditorView to call them. A plugin that puts its logic in
view(), props.handleKeyDown, props.transformPasted or props.decorations is silently
inert, and its tests pass because they call the function directly.
Use appendTransaction for document invariants, addKeymap for keys,
addPasteTransforms for paste, and editor.addOverlayRenderHandler
(from onEditorReady) for anything you would have drawn with decorations.
addPasteTransforms
Rewrites pasted content before it enters the document. PasteTransformer applies these to the
parsed slice whatever the clipboard flavour was — HTML or markdown — so a transform sees the
content exactly once.
addPasteTransforms() {
return [
(slice) => remintIdentityAttrs(slice),
];
},Use it for anything that must not survive a copy verbatim: re-minting identity attributes so a pasted node is a new instance rather than a duplicate of the original, or stripping state that only made sense in the document it came from. Transforms run in registration order, each seeing the previous one's output; return the slice unchanged to decline.
onEditorReady
Called once after the editor instance is fully constructed. Use to register subscriptions, overlay render handlers, or connect external services that need the live editor.
Return a cleanup function — it is called automatically when editor.destroy() runs.
onEditorReady(editor) {
const unsubscribe = editor.subscribe(() => {
// react to state changes
});
const unregister = editor.addOverlayRenderHandler((ctx, pageNumber, pageConfig, charMap) => {
// draw custom canvas overlay
});
return () => {
unsubscribe();
unregister();
};
},Minimal custom extension example
This extension adds a Mod-Shift-h shortcut that inserts "Hello!" at the cursor:
import { Extension } from '@scrivr/core';
const HelloExtension = Extension.create({
name: 'hello',
addKeymap() {
return {
'Mod-Shift-h': (state, dispatch) => {
if (dispatch) {
dispatch(state.tr.insertText('Hello!'));
}
return true;
},
};
},
addCommands() {
return {
sayHello: () => (state, dispatch) => {
if (dispatch) {
dispatch(state.tr.insertText('Hello!'));
}
return true;
},
};
},
});
// Use it
const editor = new Editor({
extensions: [StarterKit, HelloExtension],
});
editor.commands.sayHello();Composing sub-extensions
To bundle multiple extensions into one, return them from addExtensions(). The editor flattens
the bundle into its own extension list, so every hook a member declares is collected exactly as
if it had been listed directly:
const MyKit = Extension.create({
name: 'myKit',
addExtensions() {
const extensions = [MyExtensionA];
if (this.options.b !== false) extensions.push(MyExtensionB.configure(this.options.b));
return extensions;
},
});This is what StarterKit does. Note that a bundle should not forward its members'
contributions by hand — a bundle that re-merges nodes, marks, keymap and friends itself
silently drops any hook added after it was last edited.
For most use cases, simply pass multiple extensions to the extensions array in EditorOptions
rather than building an aggregator extension:
const editor = new Editor({
extensions: [
StarterKit,
MyExtensionA,
MyExtensionB.configure({ option: true }),
],
});