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.
{
"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 apropsbag specific to its type (a heading'slevel, a table'scolumns, …). - 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 owndata.
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.