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.