Concepts

Document model

Your document is plain JSON. Not a class, not an engine object · a tree of nodes you can log, diff, store and send over the wire.

{ type: 'doc', content: [
  { type: 'heading', attrs: { level: 1 },
    content: [{ type: 'text', text: 'Title' }] },
  { type: 'paragraph', content: [
    { type: 'text', text: 'Some ' },
    { type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
    { type: 'text', text: ' text.' },
  ] },
] }

That is the whole format. editor.getJSON() hands it to you and editor.setContent() takes it back, and nothing in between is proprietary.

Nodes and marks

A node is a thing in the document: a paragraph, a heading, an image, a table cell. A mark is something applied to a range of text: bold, a link, a comment thread. Nodes nest; marks decorate.

The distinction matters when you write an extension, because it decides three things:

Positions

Positions are integers into this tree, and the arithmetic is worth learning once because every command, every selection and every comment anchor is one.

<p>hi</p>

0  1  2  3
│  h  i  │
│           └─ 3 · after the closing border
│        └──── 2 · after "i", still inside
│  └─────────── 1 · the start of the text
└────────────── 0 · before the paragraph

So a document holding one paragraph of "hi" has a size of 4, and its content spans 0 to 3. Selecting the word means { from: 1, to: 3 }.

Never do arithmetic on a position across an await. Take a marker with ctx.mark() and map through it — see position mapping, which is the page that explains why this one is worth reading.

The schema decides what is possible

The extensions you pass build a schema, and the schema is enforced on every change. This is not decoration: a transaction that would produce an invalid document is refused, and the command that asked for it returns false.

const editor = createEditor({ extensions: [document, paragraph, text, bold] })

editor.setContent('<h1>A heading</h1><p>Some <em>italic</em>.</p>')
editor.getHTML()
// '<p>A heading</p><p>Some italic.</p>'

The heading became a paragraph and the emphasis was dropped, because there is no node or mark in that schema to hold them. A node can also name the marks it accepts — marks: '' is why the text in a code block stays literal, whatever you paste into it. That is what makes a comment box a comment box: somebody pasting three pages of a Word document into it gets three paragraphs of text, not three pages of headings and tables.

It is immutable

Every change produces a new document rather than editing the old one. Two consequences you will actually notice:

Reading it without an editor

Because it is ordinary data, you can persist it anywhere, compare two revisions with any diff library, and read it on a server without loading an editor at all.

import { toMarkdown } from '@matrajs/core'

// On a server, in a worker, in a test · no DOM anywhere.
const markdown = toMarkdown(JSON.parse(row.body))

toMarkdown walks the JSON. It never touches a DOM, which is why it runs on a server with no jsdom in sight and why it is in the benchmark rather than in a caveat.

Next

Edit this page on GitHub