Tintero Developers

How ProseMirror documents work

The shape of the JSON a chapter is stored as, how to walk it without breaking anything, and how Tintero's own editor features map onto it.

On this page

The writing itself is never in the manifest. Every chapter, doc, note and saved version keeps its text in a file of its own, and what is in that file is ProseMirror JSON: not HTML, not Markdown, not plain text. It looks unfamiliar the first time you open one, and it is friendlier than it looks, so this page walks through what it actually means.

If all you want is to move text around, the content guide hands you the converters and you can happily stop there. Come back here when you need to keep every last bit of formatting, or when you are reading files with no Tintero running at all.

The shape

A document is a tree. Every node has a type, most have a content array of the nodes inside them, and the leaves of the tree are text.

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 1 },
      "content": [{ "type": "text", "text": "Chapter One" }]
    },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "It was a bright cold day, and " },
        { "type": "text", "text": "the clocks were striking thirteen", "marks": [{ "type": "italic" }] },
        { "type": "text", "text": "." }
      ]
    }
  ]
}

Four things carry everything:

  • type names the node. The root is always doc.
  • content holds the children, in order. A node with no content is a leaf.
  • attrs holds the node’s own settings. Missing attributes fall back to their defaults.
  • marks holds formatting laid over a piece of text.

Marks are not wrappers

This is the piece that trips people coming from HTML. In HTML, italic text is inside an <em>. In ProseMirror, there is no wrapper: the text node itself carries a marks array, and a run of text with different formatting is split into several text nodes.

That is why the paragraph above is three text nodes rather than one. Formatting changes in the middle, so the run ends and a new one starts. Two neighbouring text nodes with identical marks are the same thing as one node holding both strings, and Tintero will happily write either.

The vocabulary

Tintero’s schema has 35 nodes and 17 marks, and 29 of those are its own. The full list, with what each one contains and what its attributes default to, is the document schema reference. It is generated by running the editor’s own schema builder, so it cannot fall behind the app.

The standard half behaves the way ProseMirror describes it everywhere: doc, paragraph, heading, text, bulletList, codeBlock, blockquote, table and the usual marks. For those, the ProseMirror guide and its model reference are the documentation, and Tiptap’s own schema page explains the layer Tintero builds on.

The other half is what Tintero adds, and it is the reason a generic ProseMirror reader is not quite enough.

What Tintero adds, and why

In the editorIn the JSON
A scene, with its point of view and its typeA scene node wrapping the blocks, carrying sceneId
A bookmark you jump back toA zero-width bookmark node, carrying only its id
A character or worldbuilding highlightA customHighlight mark with data-character-id or data-worldbuilding-id
A comment or note on a passageA noteHighlight mark carrying the note’s id
Dialogue formattingA dialogMark mark, and a dialog attribute on the paragraph
Screenplay modeTwelve block nodes: slugline, action, character, dialogue and the rest
A resized imageA resizableImageClean node with its own width, height and alignment
An inline narrationAn inlineAudioPlayer node pointing at a project audio asset
A link to another documentA documentLink mark carrying the target fileId
Text-to-speech directionttsVoice and ttsEmotion marks, ttsPause and ttsSoundTag nodes

Two patterns run through that table and both matter when you read a project.

Some features are half in the text and half in the manifest. A scene node carries a sceneId and nothing else; the point of view, the location and the notes are in ProjectFile.scenes. A bookmark node carries an id; its number and label are in Project.bookmarks. Change one side without the other and the app shows a scene with no detail, or a bookmark index pointing at anchors that are not there.

Some things you see in the editor are in no file at all. Spelling squiggles, the character names lit up as you scroll, the search highlights: those are decorations, drawn over the text and never saved. The editor extensions reference lists which is which, because “I can see it in the app and it is not in the JSON” is otherwise an alarming discovery.

Walking a document

Recursive, and short. Every node either has content or does not.

function walk(node, visit) {
  visit(node);

  for (const child of node.content ?? []) {
    walk(child, visit);
  }
}

function textOf(document) {
  let text = '';

  walk(document, (node) => {
    if (node.type === 'text') text += node.text;
    if (node.type === 'paragraph') text += '\n';
  });

  return text;
}

For anything beyond reading, use prosemirror-model rather than hand-rolling it. Build a schema, call Node.fromJSON, and you get position mapping, validation and a toJSON that produces something Tintero will open.

Writing one back

So the safe ways to produce a document, in order of how much can go wrong:

  1. Convert. tintero.convert.fromMarkdown() and its siblings give you a valid document, at the cost of whatever the source format cannot express.
  2. Edit what is there. Read the file, change the parts you mean to change, write it back. Everything you did not touch stays exactly as it was.
  3. Build from the schema. Use prosemirror-model with the node list from the schema reference, and let it reject the document rather than the editor.

Building nodes as bare object literals is the one to avoid. It is the approach where an attribute with no default gets left out, and nothing says so until someone opens the chapter.