Scrivr
Guides

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

Further reading

On this page