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

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 for event handling or decorations.

addProseMirrorPlugins() {
  return [
    new Plugin({
      props: {
        handleKeyDown(view, event) {
          if (event.key === 'Tab') {
            // custom tab handling
            return true; // mark as handled
          }
          return false;
        },
      },
    }),
  ];
},

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, implement the schema/behaviour hooks for each member directly, or call .resolve() on each sub-extension and merge the results. This is the pattern StarterKit uses internally — it aggregates all built-in extensions by resolving and merging their nodes, marks, keymap, commands, etc. into a single Extension.create() config.

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