Skip to content

Document Model

The document model is a tree of immutable data types. All mutations create new instances.

The root container holding an array of blocks:

interface Document {
readonly children: readonly BlockNode[];
}
import {
createDocument, createBlockNode, createTextNode,
createInlineNode, nodeType, inlineType,
} from '@notectl/core';
// Empty document (single empty paragraph)
const doc = createDocument();
// Document with content
const doc = createDocument([
createBlockNode(nodeType('heading'), [
createTextNode('Hello World'),
], undefined, { level: 1 }),
createBlockNode(nodeType('paragraph'), [
createTextNode('Some text'),
]),
]);
// InlineNode (atomic, width-1 element)
const br = createInlineNode(inlineType('hard_break'));

A block-level node (paragraph, heading, list item, etc.):

interface BlockNode {
readonly id: BlockId;
readonly type: NodeTypeName;
readonly attrs?: BlockAttrs;
readonly children: readonly ChildNode[];
}

A child of a BlockNode can be text, an inline element, or a nested block:

type ChildNode = TextNode | InlineNode | BlockNode;
TypeDescriptionAttributes
paragraphStandard text block
headingHeading (H1-H6)level: number
titleDocument title (H1 variant)
subtitleDocument subtitle (H2 variant)
list_itemList entrylistType, indent, checked
blockquoteBlock quote
code_blockCode block with syntax highlightinglanguage?: string
horizontal_ruleHorizontal line (void)
imageImage block (void)src, alt?, width?, height?
tableTable container
table_rowTable row
table_cellTable cellcolspan?, rowspan?

An inline text segment with marks:

interface TextNode {
readonly type: 'text';
readonly text: string;
readonly marks: readonly Mark[];
}
import { createTextNode, markType } from '@notectl/core';
// Plain text
const text = createTextNode('hello');
// Text with marks
const bold = createTextNode('bold text', [
{ type: markType('bold') },
]);
// Text with attributed marks
const colored = createTextNode('red text', [
{ type: markType('textColor'), attrs: { color: '#FF0000' } },
]);

An atomic inline element that occupies width 1 in offset space (e.g., hard break, mention, emoji):

interface InlineNode {
readonly type: 'inline';
readonly inlineType: InlineTypeName;
readonly attrs: Readonly<Record<string, string | number | boolean>>;
}
import { createInlineNode, inlineType } from '@notectl/core';
const br = createInlineNode(inlineType('hard_break'));
const emoji = createInlineNode(inlineType('emoji'), { name: 'rocket' });

InlineNodes are rendered with contenteditable="false" in the DOM and behave as atomic units for selection and editing.

An inline annotation applied to a text range:

interface Mark {
readonly type: MarkTypeName;
readonly attrs?: Readonly<Record<string, string | number | boolean>>;
}
TypeAttributesDescription
boldBold text
italicItalic text
underlineUnderlined text
strikethroughStrikethrough text
superscriptSuperscript text
subscriptSubscript text
linkhref: stringHyperlink
textColorcolor: stringText color
highlightcolor: stringBackground highlight
fontfamily: stringFont family
fontSizesize: stringFont size

For mark-preserving undo/redo and block range operations:

interface TextSegment {
readonly text: string;
readonly marks: readonly Mark[];
}
type ContentSegment =
| { readonly kind: 'text'; readonly text: string; readonly marks: readonly Mark[] }
| { readonly kind: 'inline'; readonly node: InlineNode };
import {
getBlockText,
getBlockLength,
getTextChildren,
getInlineChildren,
getBlockChildren,
getBlockMarksAtOffset,
getContentAtOffset,
isTextNode,
isInlineNode,
isBlockNode,
isLeafBlock,
} from '@notectl/core';
const text = getBlockText(block); // "Hello World"
const len = getBlockLength(block); // 11 (InlineNodes count as 1)
const textNodes = getTextChildren(block); // TextNode[]
const inlines = getInlineChildren(block); // (TextNode | InlineNode)[]
const blocks = getBlockChildren(block); // BlockNode[] (nested children)
const marks = getBlockMarksAtOffset(block, 5); // Mark[] at offset
const content = getContentAtOffset(block, 0); // { kind: 'text', char, marks } | { kind: 'inline', node } | null
import { hasMark, markSetsEqual, markType } from '@notectl/core';
hasMark(marks, markType('bold')); // boolean
markSetsEqual(marks1, marks2); // boolean
import {
resolveNodeByPath,
resolveParentByPath,
findNodePath,
findNode,
findNodeWithPath,
walkNodes,
} from '@notectl/core';
const node = resolveNodeByPath(doc, path); // BlockNode | undefined
const { parent, index } = resolveParentByPath(doc, path);
const path = findNodePath(doc, blockId); // string[] | undefined
const node = findNode(doc, blockId); // BlockNode | undefined
const { node, path } = findNodeWithPath(doc, blockId);
walkNodes(doc, (block, path) => { /* DFS visitor */ });
import {
isNodeOfType, isMarkOfType, isInlineNodeOfType,
isTextNode, isInlineNode, isBlockNode,
} from '@notectl/core';
// These type guards require module augmentation of the attribute registries.
// Plugins like HeadingPlugin, LinkPlugin, and HardBreakPlugin augment the
// registries automatically when imported.
if (isNodeOfType(block, 'heading')) {
console.log(block.attrs.level); // Type-safe access
}
if (isMarkOfType(mark, 'link')) {
console.log(mark.attrs.href); // Type-safe access
}
if (isInlineNodeOfType(node, 'hard_break')) {
// Type-safe InlineNode access
}

notectl uses branded types for compile-time safety:

import {
blockId, nodeType, markType, inlineType,
pluginId, commandName,
} from '@notectl/core';
const id = blockId('abc123'); // BlockId
const nt = nodeType('paragraph'); // NodeTypeName
const mt = markType('bold'); // MarkTypeName
const it = inlineType('hard_break'); // InlineTypeName
const pid = pluginId('my-plugin'); // PluginId
const cmd = commandName('toggleBold'); // CommandName

These prevent accidental mixing of string types at compile time.