Document model
The JSON shape of an Edytor document, with blocks, content, children, marks, inline blocks, ids and the rules the editor applies to them.
An Edytor document is a tree of blocks, stored as JSON. You pass it in as value, read it back from edytor.value or onChange, and store it wherever you like. This page describes that shape and what the editor guarantees about it.
The types
All four types are exported from edytor and edytor/crdt/edytor:
type JSONDoc = {
children: JSONBlock[];
};
type JSONBlock = {
type: string;
id?: string;
data?: Record<string, SerializableContent>;
content?: (JSONText | JSONInlineBlock)[];
children?: JSONBlock[];
};
type JSONText = {
text: string;
marks?: Record<string, SerializableContent>;
};
type JSONInlineBlock = {
type: string;
id?: string;
data?: any;
};
SerializableContent is any JSON value: a string, number, boolean, null, or an object of them.
Blocks: content and children
A block has two separate slots:
contentis the block’s own inline content: text runs and inline blocks, in order.childrenare nested blocks. They render inside the parent (a toggle’s body, a list item’s sub-list) and can nest to any depth.
{
"children": [
{
"type": "toggle",
"content": [{ "text": "Details" }],
"children": [
{ "type": "paragraph", "content": [{ "text": "Hidden until opened." }] },
{
"type": "bulleted-list-item",
"content": [{ "text": "Nested items work too" }],
"children": [{ "type": "bulleted-list-item", "content": [{ "text": "Deeper" }] }]
}
]
}
]
}
type names a block kind registered by a plugin (paragraph, heading, …). data holds the kind’s settings: a heading’s { level: 'h2' }, a to-do’s { checked: true }. See Blocks for the kinds, nesting rules and default children.
The document itself (JSONDoc) is the root’s children. edytor.value and onChange wrap them in a root block: { type: 'root', children }.
Text and marks
Text is a list of runs. Each run carries the marks that apply to all of its characters:
{
"type": "paragraph",
"content": [
{ "text": "Read the " },
{ "text": "docs", "marks": { "bold": true, "link": { "href": "https://example.com" } } },
{ "text": " first." }
]
}
A mark is a key in marks. Simple marks use true. Valued marks carry their value: a link { href, target? }, a color or highlight a CSS color string. The rich text plugin defines bold, italic, underline, strike, code, link, superscript, subscript, color and highlight.
A mark renders only when a plugin defines it (otherwise its text shows unformatted). When several marks apply, they wrap each other in registration order, the first registered innermost.
Inline blocks
An inline block is a non-editable element inside the text: a mention, a footnote, an equation. It has a type, an optional id and optional data:
{
"type": "paragraph",
"content": [
{ "text": "Ask " },
{ "type": "mention", "data": { "userId": "u_42", "label": "Ada" } },
{ "text": " about it." }
]
}
The plugin that defines the inline block type renders it from data. In the editor it counts as one character of the block’s content: Backspace or Delete next to it removes it whole, and clicking it selects it.
Ids
Block and inline block ids are optional in the input:
- Ids you provide are kept. Use them when something outside the document refers to a block (a link to
#block-<id>, comments, analytics). - Missing ids are generated. For the initial
value, they are derived from a hash of the value, so two clients that seed the same template get the same ids and merge into one document. - The output always has ids.
- Ids must be unique. An insert that reuses a live block’s id is refused.
- Pasted content always gets fresh ids, even when it was copied from the same document.
What you get back
The JSON the editor returns is normalized. Compare an input:
{ "type": "paragraph", "content": [{ "text": "Hel" }, { "text": "lo" }, { "text": "" }] }
with what edytor.value returns for it:
{ "type": "paragraph", "id": "b6si4c.0", "data": {}, "content": [{ "text": "Hello" }] }
idanddataare always present (datais{}when empty). Inline blocks always carryidanddatatoo.- Adjacent text runs with equal marks are merged into one run.
- Empty text runs are dropped. An empty block has no
contentkey. childrenis omitted when a block has none.
Don’t compare stored JSON with edytor.value by deep equality right after loading it; compare after one round trip.
Invariants you can rely on
- Unknown block types still render. A block whose
typeno plugin of the view defines (a peer running another plugin, a rolling deploy) renders as a plain block: its text and its children, with no roles and no custom markup. In development the view logs one warning per unknown type. Load documents with the same plugins that wrote them to render them as intended. - A block renders as text around inline blocks. However its
contentis stored, the editor shows it as a text segment first and last, and exactly one text segment between two inline blocks (possibly empty). That is where the caret goes before, between and after inline blocks. - An empty document shows one block. A new document with no children is seeded with one empty block of the default type (
paragraph). A document that becomes empty later, for example when collaborators delete every block, shows a local empty paragraph that is only written once someone types in it. - Values are JSON.
dataand mark values cross the network and storage as JSON. Non-JSON values are coerced (aDatebecomes a string, aMapbecomes{},undefinedkeys are dropped,NaNbecomesnull) and a warning is logged in development. Serialize richer values yourself. - Strings are well-formed. Unpaired UTF-16 surrogates in text, ids and data become
U+FFFD, so every replica stores the same string.
Next
- Blocks: kinds, nesting, void and island blocks, default children.
- The editor instance: reading and changing the document from code.
- Custom blocks: defining your own kinds.