jotdocs

Concepts / How content lives in Yjs

How content lives in Yjs

The whole note is one Yjs node, lexical-y. Text stays text. Each line ends in a ¶ marker that carries the line's type and fields, and blocks that aren't lines, like tables, become nested nodes. Type in the editor and watch the root change; hover a line or a row to pair them.

Reading the tree

  • The root's attributes: lexical-y:1 = true records the layout version the note needs, and k:<type> records how each element type is stored, on its first write.
  • Text carries its formats: f:bold, f:italic. A link is a format wrapper, w:link, whose value holds the link's fields.
  • A ¶ marker ends a line: t is its type (left out for a paragraph) and p:<field> its schema fields, like a heading's p:tag.
  • List items are lines in a group: g names the group's type, g:listType its identity, and l the item's level.
  • b:<type> nodes hold blocks that aren't lines, such as a table, its rows and its cells, each with lines of its own. Try typing - or ## at the start of a line.

Why lines, not nodes

Typing, splitting and joining lines are text edits: Enter inserts a marker, and Backspace at the start of a line deletes one. Yjs merges them the way it merges any text, so two people splitting the same paragraph at once both keep their text. Indenting a list item changes its marker's level, not its place in a tree.

Storage kinds

@lexical/y derives how each element type is stored from the node structure its class declares in $config(), first match wins: a block with inline children is a line; an inline element with mergeAdjacent is a format; a block whose children are only lines, with mergeAdjacent, is a group; anything else is nested. The first three also need everything the class stores to be in its schema and NodeState, since a marker or a format can only carry those. A class that doesn't qualify falls back to nested, which stores the node as its own Yjs node and is always safe. describeStorage() prints the result for your editor.

inspect.ts
import { describeStorage } from '@lexical/y';
import type * as Y from '@y/y';
import type { LexicalEditor } from 'lexical';

export function logStorage(editor: LexicalEditor, doc: Y.Doc) {
  // How each node type is stored: 'line', 'group', 'format', 'nested' or 'text'.
  console.table(describeStorage(editor));

  // The note itself: text, ¶ line markers and nested nodes, with attributes.
  const root = doc.get('lexical-y');
  console.log(JSON.stringify(root.toDelta({ deep: true }), null, 2));
}

These names are frozen as layout 1. Changing one makes a new layout version, which older clients open read only. The full wire format is in the @lexical/y README.