Reference

Recipes

The things people need on the first day, in the fewest lines that actually work.

Autosave, without saving on every keystroke

import { autosave } from '@matrajs/core'

autosave({
  delay: 800,                                          // after the last keystroke
  save: (doc) => localStorage.setItem('draft', JSON.stringify(doc)),
  restore: () => JSON.parse(localStorage.getItem('draft') ?? 'null'),
})

editor.extensionState('autosave')   // { dirty, saving, savedAt, error } for a status line
editor.commands.save()              // now, from a button

Store the JSON, not the HTML. It survives schema changes better and diffs cleanly. A dirty document is also saved when the tab is hidden and when the editor is destroyed, which is where a timer-only autosave loses the last paragraph.

Read-only

editor.setEditable(false)

The document still renders and decorations still draw, so this is the right way to show a published version rather than rendering the HTML somewhere else and hoping it matches.

Markdown, on a server

import { fromMarkdown, toMarkdown } from '@matrajs/core'

const doc = fromMarkdown(await readFile('post.md', 'utf8'))
await writeFile('post.md', toMarkdown(doc))

No DOM involved, so this runs in Node, in a worker, and at the edge.

A table of contents that is never stale

import { tableOfContents } from '@matrajs/core'

const entries = tableOfContents(editor.getJSON())
// [{ level, text, pos, id }]
entries.forEach((entry) => {
  link.onclick = () => editor.commands.select(entry.pos + 1)
})

Derived on demand rather than cached, so it cannot disagree with the document · which is how a cached outline ends up pointing at a heading that was renamed.

A slash menu, like the one on this site

The extension finds the trigger and marks the range. What it deliberately does not do is draw a list, so this is the whole of the missing half — and the same code runs on every editable paragraph of this site.

import { activeSuggestion, suggestion } from '@matrajs/core'

const editor = createEditor({
  extensions: [...starterKit, suggestion({ char: '/', name: 'slash' })],
})

const ITEMS = [
  { name: 'Heading 1', command: 'toggleHeading', arg: 1 },
  { name: 'Bulleted list', command: 'toggleBulletList' },
  { name: 'Quote', command: 'toggleBlockquote' },
]

let matches = [], index = 0

const sync = () => {
  const active = activeSuggestion(editor, 'slash')
  if (!active) return hide()
  // Offer only what this editor can do: a row for a command that is not
  // installed is a row that does nothing when it is pressed.
  matches = ITEMS
    .filter((item) => typeof editor.commands[item.command] === 'function')
    .filter((item) => item.name.toLowerCase().startsWith(active.query.toLowerCase()))
  index = 0
  show(matches, document.querySelector('.matra-suggestion').getBoundingClientRect())
}

editor.on('change', sync)
editor.on('selectionChange', sync)

// Captured, so the menu gets Enter before the editor splits the block with it.
document.addEventListener('keydown', (event) => {
  if (!open) return
  if (event.key === 'ArrowDown') { event.preventDefault(); index = (index + 1) % matches.length }
  if (event.key === 'ArrowUp') { event.preventDefault(); index = (index - 1 + matches.length) % matches.length }
  if (event.key !== 'Enter') return
  event.preventDefault()
  event.stopPropagation()
  const item = matches[index]
  const active = activeSuggestion(editor, 'slash')
  hide()
  // Take the "/query" out first: the other order turns the slash into a heading.
  editor.commands.remove(active.range)
  editor.commands[item.command](item.arg)
}, true)

Escape is already bound by the extension and stays closed afterwards, so the next arrow key does not reopen what was just dismissed.

Search highlighting

const search = (query) => ({
  kind: 'extension',
  name: 'search',
  decorations: (ctx) => matches(ctx.doc, query).map((range) => ({
    type: 'inline', from: range.from, to: range.to,
    attrs: { class: 'hit' },
  })),
})

Decorations are drawn over the document, so a highlight never ends up in a copy or an export.

Stable ids for anchoring comments

import { assignIds } from '@matrajs/core'

const doc = assignIds(editor.getJSON())   // every block gains a stable id
editor.setContent(doc)

Call it when you load and when you save, not on every transaction · an editor that rewrites attributes behind your back makes every document dirty and every undo stack strange.

A character limit

editor.on('change', () => {
  const over = editor.getText().length > 280
  button.disabled = over
})

Refuse to submit rather than refusing the keystroke. Blocking input mid-word is how you make someone lose a sentence they were pasting.

Loading HTML you did not write

editor.setContent(untrustedHtml)

Safe by construction. Executable attributes are never set, URL attributes are scheme-checked, undeclared attributes are dropped, and every route into the DOM · JSON, paste, command, decoration · passes the same gate. See SECURITY.md.

getHTML() output is safe to render in the editor. If you store it and serve it somewhere else, sanitise at that boundary too · defence in depth is the point.

Find and replace, step by step

import { createEditor, search, searchCSS, starterKit } from '@matrajs/core'

// 1. put it in the array
const editor = createEditor({ extensions: [...starterKit, search()] as const })

// 2. paste its CSS once · the editor ships no appearance
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: searchCSS }))

// 3. wire a panel
input.oninput = () => editor.commands.setSearch({ query: input.value, wholeWord: false })
next.onclick = () => editor.commands.nextMatch()          // selects it, so the view scrolls
replaceOne.onclick = () => editor.commands.replaceMatch(replacement.value)
replaceAll.onclick = () => editor.commands.replaceAllMatches(replacement.value)  // one undo step

// 4. show the count
editor.on('change', () => {
  const { matches, current } = editor.extensionState('search')
  counter.textContent = matches.length ? `${current + 1} of ${matches.length}` : 'no matches'
})

Typing while the search is open rescans the paragraph being typed in and nothing else · the other paragraphs' matches are read back from a cache keyed on the block, and the renderer leaves their elements alone.

Text colour, font and size

import { textStyle } from '@matrajs/core'

const editor = createEditor({ extensions: [...starterKit, textStyle] as const })

editor.commands.setColor('#c00')          // keeps whatever font is already set
editor.commands.setFontFamily('Georgia, serif')
editor.commands.setFontSize('1.25em')
editor.commands.unsetColor()               // the font stays
editor.commands.unsetTextStyle()           // everything off

One mark with four attributes rather than four marks, so a coloured, resized word is one span. Every value is checked against the shape of a colour, a font list or a length before it reaches a style attribute · whatever route it arrived by.

Tables people can actually edit

import { tableKit } from '@matrajs/core'

const editor = createEditor({ extensions: [...starterKit, ...tableKit] as const })

editor.commands.insertTable(3, 3)   // a header row and two body rows, caret in the first cell
// Tab moves to the next cell, Shift-Tab back, Tab in the last cell adds a row
editor.commands.addRowAfter()
editor.commands.addColumnBefore()
editor.commands.deleteColumn()
editor.commands.toggleHeaderRow()

// a toolbar knows when to show table buttons
const inTable = editor.isActive('table')

A cell that spans the boundary a new row or column crosses is widened rather than split, and a cell that spans into a deleted row moves down one shorter · the way a spreadsheet does it.

Images dropped or pasted, uploaded, and put where they landed

import { fileHandler, image } from '@matrajs/core'

const editor = createEditor({
  extensions: [
    ...starterKit,
    image,
    fileHandler({
      accept: ['image/'],
      async onDrop({ editor, files, pos, marker }) {
        for (const file of files) {
          const src = await upload(file)                       // your endpoint
          // the user kept typing while that ran · the marker says where "here" is now
          editor.commands.insert({ type: 'image', attrs: { src } }, pos && marker.map(pos))
        }
      },
      onPaste: ({ editor, files }) => /* a screenshot from the clipboard arrives here */ void 0,
    }),
  ] as const,
})

A YouTube embed

import { youtube, youtubeCSS } from '@matrajs/core'

editor.commands.insertYoutube({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', start: 42 })

Only the id is stored. The frame's address is built from it, on the privacy domain, so a document can never carry a frame pointing anywhere else.

Callouts and toggles

import { callout, calloutCSS, detailsKit, detailsCSS } from '@matrajs/core'

const editor = createEditor({ extensions: [...starterKit, callout, ...detailsKit] as const })

editor.commands.toggleCallout('warning')   // wraps the block · again to lift it back out
editor.commands.setCalloutEmoji('⚠️')

editor.commands.insertDetails()            // the block becomes a toggle with an empty summary
// Enter in the summary moves into the content; the triangle writes `open` to the document
editor.commands.toggleDetails()
editor.commands.unsetDetails()

Code with colours

import { codeHighlight, codeHighlightCSS } from '@matrajs/core'

// the built-in tokeniser: comments, strings, numbers, shared keywords
createEditor({ extensions: [...starterKit, codeHighlight()] as const })

// or the real thing · Shiki, Prism, lowlight · as a function of the code and its language
codeHighlight({
  highlight: (code, language) =>
    tokenize(code, language).map((token) => ({ from: token.start, to: token.end, color: token.color })),
})

The colours are decorations. The document keeps plain text, which is what copying the code out and saving it both want, and a block is only re-tokenised when it changes.

Links as you type, emoji as you type, Tab to indent

import { autolink, emoji, indent, clearFormatting, focus, trailingNode } from '@matrajs/core'

createEditor({
  extensions: [
    ...starterKit,
    autolink(),                         // https://… followed by a space becomes a link · pasted URLs too
    emoji({ emoticons: true }),          // :tada: and :) as you type
    indent(),                           // Tab and Shift-Tab on paragraphs and headings
    clearFormatting,                    // Mod-\ · every mark off, back to a paragraph
    focus(),                            // .has-focus on the block the caret is in
    trailingNode(),                     // always a paragraph after a table or an image
  ] as const,
})

Each is one line in the array and nothing else to wire. The ones with input rules are one undo step per replacement, so a wrong guess costs one Mod-Z.

A template: fixed clauses, blanks, and a mail merge with no editor

import { createEditor, field, fieldsCSS, fillFieldsIn, locked, lockedCSS, starterKit } from '@matrajs/core'

// 1. put both in the array
const editor = createEditor({ extensions: [...starterKit, field, locked()] as const, content })

// 2. lock the clauses nobody may edit · the caret in the block, then
editor.commands.lock()            // toggleLock(), unlock() · one undo step each
editor.can.insert('x')            // false inside a locked block, so a button greys out

// 3. blanks: type {{name}}, or
editor.commands.insertField('name', 'Full name')

// 4. fill them, here or on a server
editor.commands.fillFields({ name: 'Ada Lovelace' })          // in the editor
const letter = fillFieldsIn(template, { name: 'Ada Lovelace' }) // plain JSON, no DOM

// 5. the CSS
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: lockedCSS + fieldsCSS }))

A locked block refuses a keystroke, a paste, a drop, a drag and a command alike, because all of them are changes and the lock is a change filter. Typing beside it, and moving other blocks past it, is fine. A field is an atom: it cannot be half-deleted into {{nam}}, and fieldsIn(doc) lists what a template still needs.

Inline completion from any source

import { ghostText, ghostTextCSS } from '@matrajs/core'

ghostText({
  delay: 300,                                  // the caret rests this long before asking
  suggest: async ({ before, after }) => {
    const reply = await fetch('/complete', { method: 'POST', body: JSON.stringify({ before, after }) })
    return (await reply.json()).text           // or null for nothing
  },
})

// Tab takes it · Escape dismisses it · a word at a time if you like
editor.commands.acceptGhostWord()

The suggestion is a decoration, never part of the document: not saved, not sent to collaborators, not undone. Any keystroke or caret move dismisses it, and a reply that arrives after the document moved on is dropped, so a slow model never writes into a sentence that has changed underneath it.

Dictation

import { dictation, dictationCSS, dictationSupported } from '@matrajs/core'

const editor = createEditor({ extensions: [...starterKit, dictation({ lang: 'en-GB' })] as const })

if (dictationSupported()) button.onclick = () => editor.commands.toggleDictation()
editor.extensionState('dictation')   // { listening, interim, error }

The browser's own recogniser, so nothing is downloaded and nothing is sent anywhere the browser does not already send it. Words the recogniser is still deciding on are drawn after the caret and become text only once it settles; a space is put in front when the caret follows a word. Chrome, Edge and Safari can listen; Firefox cannot yet, and every command returns false there.

Paste what was meant

import { smartPaste } from '@matrajs/core'

createEditor({ extensions: [...starterKit, ...tableKit, smartPaste()] as const })

// a spreadsheet copied as text      →  a table, first row as headers
// a README copied from a terminal   →  headings, lists, code fences
// **bold** in one line              →  bold, in the sentence the caret is in

Only plain text is looked at; anything with real HTML on the clipboard is left to the parser, which already reads a table copied from a browser. A heading pasted into an editor that has no heading extension stays text, because that editor cannot hold one. Comma-separated text counts too, when every line has the same number of short cells; pass csv: false to turn that off.

A bubble menu and a floating menu

import { bubbleMenu, floatingMenu } from '@matrajs/core'

const bubble = document.querySelector('#bubble')      // your element, your buttons, your CSS
const plus = document.querySelector('#plus')

createEditor({
  extensions: [...starterKit, bubbleMenu({ element: bubble }), floatingMenu({ element: plus })] as const,
})

// buttons keep the selection by cancelling mousedown
bubble.querySelector('button').onmousedown = (event) => { event.preventDefault(); editor.commands.toggleBold() }

The bubble appears over a selection and hides when it collapses or focus leaves both the editor and the menu; the floating one appears on an empty top-level line. Both position the element absolutely against whatever it is positioned in, and both take a shouldShow(editor) when the default is not the rule you want.

Images that resize

import { image, imageResize, imageResizeCSS } from '@matrajs/core'

createEditor({ extensions: [...starterKit, image, imageResize({ min: 48, max: 1200 })] as const })

editor.commands.setImageWidth(320, pos)   // or drag the handle

The width is an attribute added to the stock image from outside, written to the width attribute so the HTML carries it and a browser honours it before any CSS loads. The handle is a node view from outside too: an editor without the extension renders a plain <img>.

Columns and page breaks

import { columnsKit, columnsCSS, pageBreak, pageBreakCSS } from '@matrajs/core'

createEditor({ extensions: [...starterKit, ...columnsKit, pageBreak] as const })

editor.commands.setColumns(3)     // the block at the caret becomes the first column
editor.commands.addColumn()       // up to six
editor.commands.unsetColumns()    // every column's blocks, in order, back as blocks
editor.commands.insertPageBreak() // a labelled line here, a new page in print

The columns are a CSS grid on the list, so the document says how many there are and the page decides how wide each one is. The page break's CSS includes the @media print rule, so window.print() starts a new page at each one.

Footnotes and formulas

import { footnotesKit, footnotesCSS, mathKit, mathCSS } from '@matrajs/core'
import katex from 'katex'

createEditor({
  extensions: [
    ...starterKit,
    ...footnotesKit(),
    ...mathKit({ render: (latex, element, display) => katex.render(latex, element, { displayMode: display }) }),
  ] as const,
})

editor.commands.insertFootnote()          // a marker here, a note below, the caret in the note
editor.commands.insertInlineMath('x^2')   // or type $x^2$ and a space · $$…$$ on a line for display

Footnote numbers are decorations computed from where the markers stand, so moving a paragraph renumbers everything and the document never stores a number. A formula stores only its source; the renderer is yours, and without one the source shows in a <code>. The exported HTML carries the source as text, so a page with no script still reads it.

Text case, invisible characters, the other occurrences

import { invisibleCharacters, invisibleCharactersCSS, selectionHighlight, selectionHighlightCSS, textTransform } from '@matrajs/core'

createEditor({
  extensions: [...starterKit, textTransform, invisibleCharacters(), selectionHighlight({ wholeWord: true })] as const,
})

editor.commands.sentenceCase()               // or uppercase, lowercase, capitalize, toggleCase
editor.commands.toggleInvisibleCharacters()  // a dot on every space, a pilcrow on every block

Case changes work on the selection, or on the word under the caret, and rewrite one text node at a time so a bold word stays bold. Invisible characters and the highlight on other occurrences of a selected word are decorations: never in the HTML, never in the JSON, and cached per block so keeping them on while writing costs the paragraph being written.

Direction, line height, and a line that stays put

import { lineHeight, textDirection, typewriter } from '@matrajs/core'

createEditor({ extensions: [...starterKit, textDirection(), lineHeight(), typewriter({ position: 0.4 })] as const })

editor.commands.setTextDirection('rtl')   // stored · unset it and the text decides again
editor.commands.setLineHeight(1.6)        // a checked style, round-tripped through HTML
editor.commands.toggleTypewriter()

A block whose first strong character is Arabic, Hebrew, Syriac, Thaana or NKo is drawn right to left with nothing stored, the way dir="auto" would. Typewriter scrolling keeps the caret's line at a fixed height on screen while the editor has focus, measured in the next animation frame rather than in the input path.

Snippets, hashtags, key names

import { hashtag, hashtagsIn, kbd, snippets } from '@matrajs/core'

createEditor({
  extensions: [
    ...starterKit,
    kbd,
    hashtag(),
    snippets([
      { trigger: 'sig', content: '— Nahim' },
      { trigger: 'tbl', content: { type: 'table', content: [/* rows */] } },
    ], { prefix: ';' }),
  ] as const,
})

hashtagsIn(editor.getJSON())     // ['matra', 'release'] · works on saved JSON too
editor.commands.toggleKbd()      // Mod-Alt-K

A snippet fires on the trigger typed as a whole word and a space; text keeps the space, a block stands on its own. A hashtag is a node, like a mention, so it cannot be half-deleted into #mat and the document can be asked for its tags without anyone parsing prose.

Any embed, sandboxed

import { embed, embedCSS } from '@matrajs/core'

createEditor({ extensions: [...starterKit, embed({ allow: ['player.vimeo.com', /^https:\/\/www\.figma\.com\//] })] as const })

editor.commands.insertEmbed('https://player.vimeo.com/video/1', { aspect: '4/3' })

The allowlist is checked when a command sets the address, when HTML is parsed, and again when the node renders, because a document loaded from JSON skipped the first two. Every frame is sandboxed and only https: passes. Left off, a short list of well-known players and tools applies.

Edit this page on GitHub