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.


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).


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';

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;
        const { toggleMark } = require('prosemirror-commands');
        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