noteloom

Architecture

A noteloom document is a flat, normalized tree of two kinds of records — blocks and runs — held in two Maps inside an EditorStore. Nothing is deeply nested; a block only ever knows its own contentIds, an ordered array of child ids.

json
{
  "rootId": "root",
  "blocks": [
    { "id": "root", "type": "page", "parentId": null, "contentIds": ["p1"], "props": {} },
    { "id": "p1", "type": "paragraph", "parentId": "root", "contentIds": ["r1"], "props": {} }
  ],
  "runs": [
    { "id": "r1", "type": "text", "value": "Hello", "marks": { "bold": true } }
  ]
}

Blocks vs. runs

  • A block is a structural node: a paragraph, heading, list item, table, callout, etc. It has an id, type, parentId, contentIds, and a props bag specific to its type (a heading's level, a table's columns, …).
  • A run is a leaf of inline content: a span of styled text (type: "text"), or an atomic inline widget (type: "select", "date", "checkbox", …) with its own data.

Whether a given id in contentIds points at another block or at a run is never encoded on the store itself — it's discovered by membership, since ids are unique across both maps. This is what lets leaf blocks (paragraph, heading, blockquote, code, button — see each block's isLeaf: true) hold runs directly in contentIds, while container blocks (page, callout, layout column, table row) hold other blocks instead.

Registries

A BlockRegistry maps a block type string to its rendering/behavior contract: component, isLeaf, toHTML/fromHTML/toPlainText, and any slash commands it contributes. An InlineRegistry does the same for run types. Nothing in the store or React bindings hardcodes which block/inline types exist — registerBuiltInBlocks and registerBuiltInInlineTypes are just convenience calls that populate a plain registry, and you can register your own alongside or instead of them (see Registering custom blocks).

Store and history

EditorStore is the source of truth: getBlock(id)/getRun(id) for reads,applyOperation(op) for writes, and a per-id subscribe(id, listener) for change notification. Every write replaces only the object(s) that actually changed, so a read for an untouched id returns a referentially stable value across any unrelated update.

History wraps a store with the exact same read/write surface (so anything written against a plain store works unchanged), adding operation-based undo/redo: rapid text edits to the same run coalesce into one undo step via a short idle timeout and a word-boundary heuristic, while structural operations (insert/remove/move block) are always their own step. It also keeps an unbounded audit log (getHistoryLog()) of who changed what, when — separate from the undo/redo stack, since undoing shouldn't erase that history.

Rendering

<BlockChildren parentId="root" /> renders a container's child blocks in order. Each block subscribes only to its own id via useSyncExternalStore (see useBlock/useRun) — editing one paragraph in a 500-block document re-renders only that block's own component tree, with no virtual-DOM diff over the rest of the page and no contentEditable/React fighting over the same DOM nodes. There's a regression test guarding exactly this (test/performance/largeDocument.test.jsx in the package source).

Commands and selection

The slash menu, @-mentions, emoji picker, and floating format toolbar are all separate hooks (useSlashMenuTrigger, useAtMenuTrigger, useEmojiMenuTrigger, useFloatingToolbarTrigger) that watch document's selection and text input inside a container ref, resolving against the registries' own slashCommand(s)/atCommand(s) metadata. They all render through the same generic <SlashMenu /> component (it's a filterable popover, not slash-specific) — pass a distinct menuId/ariaLabel per instance so multiple menus never collide if more than one is mounted at once.

See the API reference for the full exported surface, or Blocks for what each block type actually does.