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:
typenames the node. The root is alwaysdoc.contentholds the children, in order. A node with nocontentis a leaf.attrsholds the node’s own settings. Missing attributes fall back to their defaults.marksholds 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 editor | In the JSON |
|---|---|
| A scene, with its point of view and its type | A scene node wrapping the blocks, carrying sceneId |
| A bookmark you jump back to | A zero-width bookmark node, carrying only its id |
| A character or worldbuilding highlight | A customHighlight mark with data-character-id or data-worldbuilding-id |
| A comment or note on a passage | A noteHighlight mark carrying the note’s id |
| Dialogue formatting | A dialogMark mark, and a dialog attribute on the paragraph |
| Screenplay mode | Twelve block nodes: slugline, action, character, dialogue and the rest |
| A resized image | A resizableImageClean node with its own width, height and alignment |
| An inline narration | An inlineAudioPlayer node pointing at a project audio asset |
| A link to another document | A documentLink mark carrying the target fileId |
| Text-to-speech direction | ttsVoice 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:
- Convert.
tintero.convert.fromMarkdown()and its siblings give you a valid document, at the cost of whatever the source format cannot express. - 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.
- Build from the schema. Use
prosemirror-modelwith 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.