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:
layout | Meaning |
|---|---|
{ 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.