Scrivr
GuidesNodes & Extensions

Custom Extensions

Write custom schema extensions — add new node types, marks, layout strategies, and canvas decorators.

The guides/plugins page covers behaviour extensions (keymaps, commands, lifecycle hooks). This page covers extensions that add new content types to the schema — custom block nodes, custom marks, and the canvas rendering strategies that go with them.


Importing ProseMirror

Extensions need ProseMirror types and helpers — Node, Slice, Plugin, toggleMark. Import them from @scrivr/core/pm, not from the prosemirror-* packages directly:

import { Plugin, PluginKey, Fragment, toggleMark } from '@scrivr/core/pm';
import type { Node, EditorState } from '@scrivr/core/pm';

@scrivr/core owns the ProseMirror versions and re-exports them, so your extension runs against the same instance as the engine. That matters: instanceof checks on Node, Slice and Selection are load-bearing, and a second copy of prosemirror-model in the same process breaks them in ways that surface as unexplained failed transactions. You also don't declare the dependency yourself.

prosemirror-view is not re-exported and is not a dependency. Scrivr paints to canvas and has no EditorView, so Plugin.spec.view() and plugin props (handleKeyDown, transformPasted, decorations) never run. Use appendTransaction, addKeymap, or PasteTransformer instead.


Layout strategies

Every block node must register a BlockStrategy — the object the canvas renderer calls to draw that block and record where each character sits on screen.

Read Layout Strategies for a full explanation of what strategies are, what TextBlockStrategy does, and when to write your own.

The short version: use TextBlockStrategy for any block that contains inline text. It handles text rendering, mark decorators, inline images, and CharacterMap registration. Only write a custom strategy for blocks with non-text content (tables, diagrams).

Declaring layout participation

How a node participates in layout is a separate dimension from what it means in the document tree, so the node spec declares it:

layoutMeaning
{ kind: 'block' }The node occupies its own box in the flow, painted by the BlockStrategy it registers. This is the default when layout is omitted.
{ kind: 'transparent' }The node contributes no box of its own. It stays in the editor tree, and its children lay out into the enclosing flow as if the boundary weren't there.

A node can matter enormously to the model and have no visual existence of its own — it carries identity, provenance, actions and a lifecycle, but the reader only ever sees its children. That node is transparent:

addNodes() {
  return {
    sourcedBlock: {
      content: 'block+',
      group: 'block',
      layout: { kind: 'transparent' },
      // ...attrs, parseDOM, toDOM
    },
  };
},

The document keeps its structure — sourcedBlock → heading, paragraph, paragraph — while the layout stream sees heading, paragraph, paragraph. Detaching such a node removes the boundary without touching the content inside it.

Declare transparent rather than leaving layout off: an undeclared block+ node falls through to the text-block path and lays out as a single empty line, painting nothing.


Adding a custom block node

A block extension contributes a ProseMirror node spec, a layout strategy, and optionally block styles (font and spacing).

import { Extension, TextBlockStrategy } from '@scrivr/core';

const Callout = Extension.create({
  name: 'callout',

  addNodes() {
    return {
      callout: {
        group: 'block',
        content: 'inline*',
        attrs: { type: { default: 'info' } }, // 'info' | 'warn' | 'error'
        parseDOM: [{ tag: 'div[data-callout]', getAttrs: (dom) => ({
          type: (dom as HTMLElement).getAttribute('data-type') ?? 'info',
        }) }],
        toDOM: (node) => ['div', { 'data-callout': '', 'data-type': node.attrs['type'] }, 0],
      },
    };
  },

  addLayoutHandlers() {
    // TextBlockStrategy handles all text rendering and CharacterMap registration
    return { callout: TextBlockStrategy };
  },

  addBlockStyles() {
    return {
      callout: {
        font: '14px Georgia, serif',
        spaceBefore: 12,
        spaceAfter: 12,
        align: 'left' as const,
      },
    };
  },

  addCommands() {
    return {
      setCallout: (attrs: { type: string }) => (state, dispatch) => {
        const callout = state.schema.nodes['callout'];
        if (!callout) return false;
        if (dispatch) {
          dispatch(state.tr.setBlockType(
            state.selection.from,
            state.selection.to,
            callout,
            attrs,
          ));
        }
        return true;
      },
    };
  },
});

Adding a custom mark

Custom marks contribute a ProseMirror mark spec and optionally a MarkDecorator for canvas rendering (e.g. drawing a background colour or underline).

import { Extension } from '@scrivr/core';
import type { MarkDecorator } from '@scrivr/core';
import { toggleMark } from '@scrivr/core/pm';

const Spoiler = Extension.create({
  name: 'spoiler',

  addMarks() {
    return {
      spoiler: {
        parseDOM: [{ tag: 'span[data-spoiler]' }],
        toDOM: () => ['span', { 'data-spoiler': '' }, 0],
      },
    };
  },

  addMarkDecorators() {
    const decorator: MarkDecorator = {
      // Draw a solid black rectangle behind the text (hides it until revealed)
      decoratePre(ctx, rect) {
        ctx.fillStyle = '#000000';
        ctx.fillRect(rect.x, rect.y - rect.ascent, rect.width, rect.ascent + rect.descent);
      },
    };
    return { spoiler: decorator };
  },

  addCommands() {
    return {
      toggleSpoiler: () => (state, dispatch) => {
        const mark = state.schema.marks['spoiler'];
        if (!mark) return false;
        return toggleMark(mark)(state, dispatch);
      },
    };
  },
});

MarkDecorator interface

The decorator runs during canvas rendering, once per text span that carries the mark:

interface MarkDecorator {
  // Called before the text is drawn — use for backgrounds and highlights
  decoratePre?(ctx: CanvasRenderingContext2D, rect: SpanRect): void;

  // Return a CSS colour string to override the text fill colour
  decorateFill?(rect: SpanRect): string | undefined;

  // Called after the text is drawn — use for underlines and strikethroughs
  decoratePost?(ctx: CanvasRenderingContext2D, rect: SpanRect): void;
}

SpanRect gives you the bounding box of the span in page-local coordinates:

interface SpanRect {
  x: number;
  y: number;        // baseline y
  width: number;
  ascent: number;   // pixels above baseline
  descent: number;  // pixels below baseline
  markAttrs: Record<string, unknown>; // attrs from the mark
}

Font modifiers

If your mark changes how text is measured (size, weight, family), register a FontModifier via addFontModifiers(). The modifier receives a parsed font object and mutates it:

addFontModifiers() {
  return new Map([
    ['spoiler', (parsed) => {
      // spoiler text renders at half opacity — no size change needed
      // but if you needed to change size: parsed.size = 12;
    }],
  ]);
},

Use addFontModifiers for marks that affect text metrics (bold, italic, size, family). Use addMarkDecorators for visual-only effects that don't change measurement.


Further reading

On this page