Concepts
Writing an extension
An extension is a plain object with the same power as the built-ins. There is no base class
to extend, nothing to register, and no declaration merging · pass it to
createEditor and its commands appear on editor.commands, typed.
A mark
import type { Command, MarkDef } from '@matrajs/core'
export const spoiler: MarkDef<{ toggleSpoiler: Command }> = {
kind: 'mark',
name: 'spoiler',
parseDOM: [{ tag: 'span[data-spoiler]' }],
toDOM: () => ['span', { 'data-spoiler': '', class: 'spoiler' }, 0],
commands: {
toggleSpoiler: (ctx) => ctx.toggleMark('spoiler'),
},
keys: { 'Mod-Shift-S': 'toggleSpoiler' },
}
The generic is what makes editor.commands.toggleSpoiler exist and be typed. It is
the only ceremony, and it buys autocomplete on every call site.
A node
export const callout: NodeDef<{ setCallout: Command<[tone: string]> }> = {
kind: 'node',
name: 'callout',
group: 'block',
content: 'block+',
attrs: { tone: { default: 'note' } },
parseDOM: [{
tag: 'div[data-callout]',
getAttrs: (dom) => ({ tone: dom.getAttribute('data-callout') }),
}],
toDOM: (node) => ['div', { 'data-callout': node.attrs?.tone }, 0],
commands: {
setCallout: (ctx, tone) => ctx.wrapIn('callout', { tone }),
},
} onclick to a node type that spreads its attrs.
Input rules
Text that becomes something else as it is typed. Each match is one undo step.
inputRules: [{
match: /^>>\s$/,
handler: (ctx, _match, range) => ctx.delete(range) && ctx.wrapIn('callout'),
}] State
An extension can keep state, reduced on every transaction. This is how the character counter works without publishing a global.
export const wordGoal = (target: number): ExtensionDef<Record<string, never>, boolean> => ({
kind: 'extension',
name: 'wordGoal',
state: {
init: (ctx) => count(ctx.doc) >= target,
apply: (ctx) => count(ctx.doc) >= target,
},
})
// anywhere
editor.extensionState<boolean>('wordGoal') Decorations
Drawing over the document without putting anything in it · search highlights, remote cursors, squiggles. Decorations never travel with a copy, an export or an undo.
decorations: (ctx) => findMatches(ctx.doc, query).map((range) => ({
type: 'inline',
from: range.from,
to: range.to,
attrs: { class: 'search-hit' },
})) Attributes on somebody else's node
Alignment belongs on a paragraph, but the paragraph should not have to know about alignment.
An extension may declare an attribute for nodes and marks defined elsewhere: it lands in the
schema of every type named, is rendered onto the element, and is read back on parse. This is
how textAlign, indent and uniqueId work.
export const tone: ExtensionDef = {
kind: 'extension',
name: 'tone',
attributes: [{
types: ['paragraph', 'heading'],
attrs: {
tone: {
default: null,
render: (value) => ({ 'data-tone': String(value) }), // onto the element
parse: (dom) => dom.getAttribute('data-tone'), // and back off it
},
},
}],
commands: {
setTone: (ctx, tone) => ctx.setBlockType('paragraph', { tone }),
},
}
Leave render and parse off and the value goes to
data-<name>. A style or class the render returns
is composed with the node's own rather than replacing it. A node that declares the attribute itself
keeps its own rendering, and the global is not applied twice.
Paste and drop
An extension can claim what arrives from the clipboard or by drag before the editor parses
it. Return true and the editor leaves it alone; anything else and the next handler,
then the editor, gets its turn. Files come through the same way — a screenshot pasted from the
clipboard is a file — and a drop carries the position it landed on.
handlePaste: (ctx, { html, text, files }) => {
if (text !== 'magic') return false
return ctx.insert('✨')
},
handleDrop: (ctx, { files, pos }) => {
const marker = ctx.mark() // survives the upload
upload(files[0]).then((src) =>
editor.commands.insert({ type: 'image', attrs: { src } }, pos && marker.map(pos)))
return true
} fileHandler() is this, packaged: an accept list and a callback with
the editor, the files, the position and the marker already in it.
Whitespace in code
HTML collapses runs of whitespace and so does the parser — except inside a node that says
code: true, and inside any <pre>, where every space and line
break is content. The stock code block says it; a node of your own that holds code should
too.
Refusing a change
An extension can veto a change before it lands. The context it gets is built on the document
as it is, holding the change as it would be, so it can compare the two; return
false and the document, the selection and the undo history stay exactly as they were.
A keystroke, a paste, a drop, a drag and a command are all changes, so one filter covers them
all — which is how locked() works — and editor.can asks the same question,
so a button greys out rather than doing nothing.
const textLength = (node) =>
node.text?.length ?? (node.content ?? []).reduce((n, child) => n + textLength(child), 0)
filterChange: (ctx) => textLength(ctx.doc) <= 280 ctx.doc is the document as it would be, not as it is, so a filter reads the proposed
change directly rather than reconstructing it. Everything a filter needs is on
Ctx.
One caveat, because the bundled extensions do more than this. locked() also lets
undo, redo and setContent through by reading the metadata on the transaction, and
that reaches past Ctx into the engine — deliberately not public, and not covered
by semver. A filter you write sees the proposed document and no more; if you need a change to
be reversible, keep the rule narrow enough that undoing it is not itself refused.
Rendering somebody else's node
A node view belongs to the node that needs one — except when the need belongs to another
extension. Resize handles belong on an image, and the image should not have to know about
them. An extension may render nodes defined elsewhere, keyed by name; its view wins over the
node's own. This is how imageResize() works, alongside a width it adds
with attributes.
nodeViews: {
image: ({ node, getPos, editor }) => {
const dom = document.createElement('span')
// … an <img> and a handle
return { dom, update: (next) => next.type === 'image', stopEvent: (event) => event.target === handle }
},
} Ordering
Extensions load in array order, adjustable with priority. Later ones win a key
binding conflict, and a more specific parseDOM rule needs a higher priority to beat
a general one · which is how a task list beats a plain bulleted list for the same
<ul>.