Getting started

Your first editor

Ten lines, and the result is the editor on the front page.

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

const editor = createEditor({
  extensions: starterKit,
  content: '<p>Hello.</p>',
  element: document.querySelector('#editor'),
})

element mounts it there and then. Leave it out and you get an unmounted editor to mount yourself later, which is what the framework bindings do · in React the element does not exist until after the first render.

It will look like unstyled text, and that is correct. Matra ships no appearance at all — a heading looks like a heading because your stylesheet says so. Styling is fifteen lines and worth reading now rather than after you have decided something is broken.

What is in the starter kit

Seventeen extensions: document, paragraph, text, heading, blockquote, codeBlock, bulletList, orderedList, listItem, horizontalRule, hardBreak, bold, italic, strike, code, link and history. Enough to write with, and nothing that needs configuring.

Not in it, on purpose: typography (curling quotes is a decision), taskList, tables, and everything that needs options. Add what you want.

// Everything:
createEditor({ extensions: starterKit })

// Or exactly what you want, and nothing else:
createEditor({ extensions: [document, paragraph, text, bold, underline] })

// Or the kit plus something:
createEditor({ extensions: [...starterKit, taskList, taskItem, ...tableKit] })

The array is the feature list. An editor built from the second line has no way to make a heading — no command, no shortcut, and pasting one flattens it to a paragraph. That is how you build a comment box that stays a comment box.

A toolbar

Every button is a command call. editor.commands is typed from the array you passed, so toggleBold exists because bold is in the kit — not because of a declaration merge somewhere.

const bold = document.querySelector('#bold')

bold.addEventListener('mousedown', (event) => {
  event.preventDefault()          // keep the caret in the editor
  editor.commands.toggleBold()
})

mousedown with preventDefault, not click. A click moves focus to the button first, which collapses the selection — so the command runs on a caret rather than on the words the user chose, and bold appears to do nothing.

A toolbar that tells the truth

A button that looks the same whether or not the thing it does is already done is a button you have to check your text to use. Ask the document:

const paint = () => {
  // Is it on?
  bold.setAttribute('aria-pressed', String(editor.isActive('bold')))
  h2.setAttribute('aria-pressed', String(editor.isActive('heading', { level: 2 })))

  // Is it even possible here? No, inside a code block.
  bold.disabled = !editor.can.toggleBold()
  undo.disabled = !editor.can.undo()
}

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

Two different questions, and a toolbar needs both. isActive asks whether the thing is done; editor.can asks whether it could be. A button that answers only the first looks pressable over a code block, gets pressed, and does nothing.

selectionChange matters as much as change: moving the caret into bold text does not change the document, and the button still has to light up · or grey out.

Reading the document

editor.getJSON()   // plain JSON, no engine types
editor.getHTML()   // a string
editor.getText()   // block-separated text

Store the JSON. It is ordinary data: you can diff two revisions with any library, log it, and read it in a language that has never heard of this editor. HTML is for display and for pasting somewhere else — round-tripping through it loses anything your schema expresses that HTML does not.

Reacting to changes

const off = editor.on('change', () => save(editor.getJSON()))

// later
off()

on returns its own unsubscribe, so there is nothing to name and nothing to match up. The events are change, selectionChange, focus and blur.

Do not save on every keystroke. Recipes has an autosave that waits for a pause and does not lose the last edit when the tab closes.

Setting content later

editor.setContent(await load(id))   // JSON or an HTML string

setContent replaces the document and clears the undo history — which is right when you are loading a different document, and wrong if you meant to change the one in front of the user. For that, use a command: editor.commands.replace(range, content).

Cleaning up

editor.destroy()

Removes the listeners and the contenteditable attribute, so the element is safe to reuse. Every framework binding does it for you on unmount; if you are mounting by hand, a route change or a hot reload is where forgetting shows up.

Where people get stuck

Next

Edit this page on GitHub