Scrivr
Concepts

The Document Model

How Scrivr represents documents using ProseMirror's node tree — and how that maps to the canvas renderer.

Scrivr uses ProseMirror as its document model. ProseMirror provides an immutable, schema-validated node tree that acts as the single source of truth for the entire editor. The canvas renderer reads from it, Yjs syncs it across peers, and the export layer serializes it. Nothing holds a shadow copy.


The Node Tree

A ProseMirror document is a tree of nodes. Every node has a type, optional attributes, and optional content. The root is always a doc node.

doc
├── heading  { level: 1 }
│   └── text "Hello, World"
├── paragraph
│   ├── text "This is "
│   ├── text "bold"  [mark: bold]
│   └── text " text."
└── bulletList
    └── listItem
        └── paragraph
            └── text "An item"

Block nodes (like paragraph, heading, bulletList) define the document's vertical structure. Inline nodes (like text, image) live inside blocks. Marks (like bold, italic, link) are annotations applied to ranges of inline content.


The Schema is Extension-Defined

Scrivr has no hardcoded node types. The ProseMirror schema is assembled from the extensions you provide at construction time:

const editor = new Editor({
  extensions: [StarterKit],
});
// editor.schema contains: paragraph, heading, text, bold, italic, link, ...

Every extension contributes one or more node or mark type definitions. StarterKit bundles the most common ones. The merged schema is available as editor.schema.


Immutable State

EditorState is immutable. Every edit produces a brand-new state object via a Transaction. Reference equality (prevState !== nextState) is enough to know the document changed — which is why editor.getSnapshot() works directly with React's useSyncExternalStore.

editor.subscribe(() => {
  const state = editor.getSnapshot(); // always the latest EditorState
});

Doc Positions

ProseMirror identifies every point in a document with an integer position. Position 0 is before the first character; positions increment through each token in the tree (opening tags, characters, closing tags).

doc:              0
  heading:        1
    text "Hello"  1 2 3 4 5
  /heading:       6
  paragraph:      7
    text "World"  8 9 10 11 12
  /paragraph:     13

Doc positions are what SelectionSnapshot exposes (.anchor, .head, .from, .to). The CharacterMap translates these integer positions into pixel (x, y) canvas coordinates.


Reading the Document

MethodReturnsUse case
editor.getState()EditorStateAccess the full ProseMirror state
editor.getState().doc.toJSON()objectSerialize to a plain JSON tree
editor.getMarkdown()stringSerialize to Markdown
editor.getSelectionSnapshot()SelectionSnapshotToolbar / menu state
editor.isActive('bold')booleanCheck mark/block at cursor

One Model, Multiple Consumers

                 ┌──────────────┐
                 │  EditorState │  ← single source of truth
                 └──────┬───────┘
          ┌─────────────┼──────────────┐
          ▼             ▼              ▼
    Canvas Renderer   Yjs / Hocuspocus  @scrivr/export
    (layout + paint)  (real-time sync)  (PDF / DOCX / MD)

The canvas renderer, collaboration layer, and export utilities all read from the same EditorState. No synchronisation between them is needed because they share the same model.

On this page