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 }),
  },
}
Declare every attribute you intend to keep. Undeclared attributes are dropped on the way in — that is a security property, not an oversight, and it is what stops a document supplying 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.

filterChange: (ctx) => {
  const { state, tr } = engine(ctx)   // the state before, the transaction as built
  return !tr.docChanged || tr.doc.textContent.length <= 280
}

Undo and redo, and setContent, carry a meta the filter can look for, so a change that was allowed in can always be allowed back out.

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

Edit this page on GitHub