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
- "It looks like plain text." It does. See styling.
- "My toolbar button does nothing." Almost always
clickinstead ofmousedownwithpreventDefault. If it is right and the button still does nothing, askeditor.can· the answer is usually that the caret is somewhere the command refuses. - "The command does not exist." Its extension is not in your array. That is the design, and TypeScript is telling you before the browser would.
- "Two carets appeared." The element was mounted twice. Guard with
if (!editor.unsafe.view) editor.mount(element)— see frameworks. - "Pasting loses my formatting." The schema has no node or mark for it. Add the extension, or accept it — a comment box refusing headings is doing its job.
Next
- Styling · because it will look wrong until you read it.
- Frameworks · React, Vue, Svelte, Solid and everything else.
- Commands · what else the editor can be told to do.