Hand-rolled, dependency-free, GX-governed rich-text editor — JSON doc model, invertible transactions, markdown (web-v1 dialect) round-trip, contenteditable view with IME-safe read-back, agent read/suggest/write surface.
activeStatefunction activeState(core: EditorCore, sel: SelectionState | null): ActiveStateToolbar-facing state (spec §9.2).
applyStepfunction applyStep(doc: DocNode, step: Step): string | nullblockElOffunction blockElOf(root: Element, node: Node): Element | nullNearest ancestor (or self) carrying data-block-id, bounded by root.
canExecutefunction canExecute( core: EditorCore, sel: SelectionState | null, cmd: Command, features?: FeaturesConfig, ): booleancontainerLengthfunction containerLength(node: InlineContainer): numberUTF-16 code-unit length of a container's flattened text (A3).
containerTextfunction containerText(node: InlineContainer): stringdiffToStepsfunction diffToSteps(doc: DocNode, blockId: string, domText: string): Step[]Flat single-container char diff (common prefix/suffix) → minimal steps.
docEqualsIgnoringIdsfunction docEqualsIgnoringIds(a: DocNode, b: DocNode): booleanStructural doc equality modulo ids (round-trip acceptance A4).
emptyDocfunction emptyDoc(): DocNodeexecuteCommandfunction executeCommand( core: EditorCore, sel: SelectionState | null, cmd: Command, features?: FeaturesConfig, origin: Origin = 'user.command', ): booleanExecute a command against the resolved selection.
findContainerfunction findContainer(doc: DocNode, id: string): Located | nullFind any node (block, listItem) by id.
freshIdfunction freshId(): stringfromMarkdownfunction fromMarkdown(src: string): DocNodeMarkdown → DocNode.
inlineContainersfunction inlineContainers(doc: DocNode): InlineContainer[]Document-ordered list of inline containers (listItems expanded in place).
invertStepfunction invertStep(step: Step, docBefore: DocNode): StepmapPointfunction mapPoint(step: Step, p: Point): PointmarkAtfunction markAt(node: InlineContainer, offset: number): Mark | nullMark of the code unit left of `offset` — what typed text inherits.
matchInputRulesfunction matchInputRules( doc: DocNode, blockId: string, caretOffset: number, trigger: string, features: FeaturesConfig, extraRules: InputRule[] = [], ): { rule: InputRule; steps: Step[]; caretAfter: Point } | nullFind the first matching rule for the text before the caret.
migratefunction migrate(doc: unknown): DocNodeVersioned loader (I7): v1 loaders reject unknown major versions, forward-migrate known ones.
normalizeRunsfunction normalizeRuns(runs: TextNode[]): TextNode[]Normalize runs (I3): merge adjacent equal-mark runs, drop empty runs.
parseInlineToRunsfunction parseInlineToRuns(src: string): TextNode[]plainTextToBlocksfunction plainTextToBlocks(text: string): BlockNode[]Plain-text paste: blank lines split paragraphs; single newlines too (no hard breaks).
readDomSelectionfunction readDomSelection(root: Element): SelectionState | nullreconcileStepsfunction reconcileSteps(doc: DocNode, blockId: string, containerEl: Element): Step[]Structure-aware reconciliation step for one container: null when the DOM already equals the model.
resolveFeaturesfunction resolveFeatures(f?: FeaturesConfig | null): FeaturesConfigrunsFromDomfunction runsFromDom(containerEl: Element): TextNode[]Container element → normalized runs (structure-aware read, A2).
safeHreffunction safeHref(href: string): string | nullsafeHref — the one link-safety contract (spec §6.1 step 4, threat T2).
sanitizeHtmlToBlocksfunction sanitizeHtmlToBlocks(html: string, features?: FeaturesConfig): BlockNode[]HTML string → BlockNode[] via the inert-DOMParser allowlist walk.
toDomfunction toDom(root: Element, point: Point): { node: Node; offset: number } | nullModel Point → DOM (node, offset).
toJSONfunction toJSON(doc: DocNode): DocNodeCanonical JSON (spec §8.1): the DocNode itself with stable key order and no undefineds — what persistence and agents see.
toMarkdownfunction toMarkdown(doc: DocNode): stringtoModelfunction toModel(root: Element, node: Node, offset: number): Point | nullDOM (node, offset) → model Point.
validateDocfunction validateDoc(doc: DocNode): string | nullInvariant check (spec §1.3) — run after every transaction; violation rejects the whole transaction.
writeDomSelectionfunction writeDomSelection(root: Element, state: SelectionState): booleanbuiltinRulesconst builtinRules: InputRule[]defaultFeaturesconst defaultFeatures: Required< Pick<FeaturesConfig, 'headings' | 'lists' | 'blockquote' | 'link'> > & FeaturesConfigAgentGatewayclass AgentGatewayEditorCoreclass EditorCoreEditorViewclass EditorViewActiveStateinterface ActiveState {
marks: Set<'strong' | 'em' | 'code' | 'link'>
blockType: 'paragraph' | 'heading' | 'blockquote' | 'listItem' | null
headingLevel: 1 | 2 | 3 | null
listOrdered: boolean | null
canUndo: boolean
canRedo: boolean
}AgentCallResultinterface AgentCallResult {
ok: boolean
code?: string
/** For suggest mode: the staged proposal id. */
proposalId?: string
result?: unknown
}AgentProposalinterface AgentProposal {
id: string
action: string
origin: `agent:${string}`
steps: Step[]
createdAt: number
}DocNodeinterface DocNode {
schema: 'aihu-editor/doc'
version: 1
children: BlockNode[]
}DocOutlineEntryinterface DocOutlineEntry {
id: string
type: BlockNode['type']
level?: number
text: string
}EditorViewOptionsinterface EditorViewOptions {
features?: FeaturesConfig
readonly?: boolean
inputRules?: InputRule[]
}FeaturesConfiginterface FeaturesConfig {
headings?: boolean
lists?: boolean
blockquote?: boolean
codeBlock?: boolean
table?: boolean
link?: boolean
inputRules?: boolean | { disable?: string[] }
}FeaturesConfig (spec §9.1): off ⇒ commands rejected, paste content degrades to text, input rules pruned.
HeadingAttrsinterface HeadingAttrs {
level: 1 | 2 | 3
}InputRuleinterface InputRule {
id: string
scope: 'blockStart' | 'inline'
/** Char whose insertion arms the check ('\n' = Enter, checked pre-split). */
trigger: string
/** Applied to block text [0, caret); must end at the caret. */
match: RegExp
apply(m: RegExpExecArray, ctx: InputRuleContext): Step[]
}InputRuleContextinterface InputRuleContext {
blockId: string
caret: Point
doc: DocNode
}ListAttrsinterface ListAttrs {
ordered: boolean
}ListItemNodeinterface ListItemNode {
id: string
type: 'listItem'
content: TextNode[]
}Pointinterface Point {
block: string
offset: number
}A position: (inline-container id, UTF-16 code-unit offset into the flattened inline text — mark-transparent).
SelectionContextinterface SelectionContext {
selection: SelectionState | null
/** Up to 80 code units either side of the caret/range, for grounding "insert here". */
textAround: { before: string; after: string } | null
}TextNodeinterface TextNode {
text: string
mark: Mark | null
}A flat run of text carrying at most ONE mark (invariant I4 — dialect guard).
Transactioninterface Transaction {
id: string
time: number
origin: Origin
steps: Step[]
selectionAfter?: SelectionState
}AgentAccesstype AgentAccess = 'none' | 'read' | 'suggest' | 'write'ApplyResulttype ApplyResult = { ok: true; tr: Transaction } | { ok: false; code: string; step?: Step }BlockNodetype BlockNode =
| { id: string; type: 'paragraph'; content: TextNode[] }
| { id: string; type: 'heading'; attrs: { level: 1 | 2 | 3 }; content: TextNode[] }
| { id: string; type: 'list'; attrs: { ordered: boolean }; children: ListItemNode[] }
| { id: string; type: 'blockquote'; content: TextNode[] }
| { id: string; type: 'hr' }Commandtype Command =
| { type: 'toggleMark'; mark: 'strong' | 'em' | 'code' }
| { type: 'setLink'; href: string | null }
| { type: 'setBlockType'; block: 'paragraph' | 'blockquote' }
| { type: 'setBlockType'; block: 'heading'; level: 1 | 2 | 3 }
| { type: 'toggleList'; ordered: boolean }
| { type: 'insertHr' }
| { type: 'undo' }
| { type: 'redo' }Disposetype Dispose = () => voidInlineContainertype InlineContainer = Extract<BlockNode | ListItemNode, { content: TextNode[] }>Any node that carries inline content and can be addressed by a Point.
Marktype Mark =
| { type: 'strong' }
| { type: 'em' }
| { type: 'code' }
| { type: 'link'; attrs: { href: string } }Origintype Origin =
| 'user.typing'
| 'user.command'
| 'user.paste'
| 'inputrule'
| 'history'
| 'dom.readback'
| 'load'
| `agent:${string}`Transaction origins (spec §2, amended per Phase-0: `dom.readback` is the MutationObserver recovery path's distinct origin — G3 attribution).
SelectionStatetype SelectionState =
| { type: 'caret'; at: Point }
| { type: 'range'; anchor: Point; head: Point } // anchor = where drag started
| { type: 'node'; block: string }Steptype Step =
| { t: 'insertText'; at: Point; text: string; mark: Mark | null }
| { t: 'insertRuns'; at: Point; runs: TextNode[] }
| { t: 'deleteRange'; from: Point; to: Point } // same container
| { t: 'setMark'; from: Point; to: Point; mark: Mark | null }
| { t: 'setRuns'; id: string; runs: TextNode[] } // whole-container run replace (inverse carrier)
| {
t: 'splitBlock'
at: Point
newId: string
/** Tail container kind. Default: same type for paragraph/listItem; paragraph for heading/blockquote. */
tail?: { type: 'paragraph' | 'heading' | 'blockquote' | 'listItem'; attrs?: HeadingAttrs }
}
| { t: 'mergeBlock'; first: string; second: string }
| {
t: 'insertBlock'
after: string | null // null = start (of doc, or of `in` list)
node: BlockNode | ListItemNode
/** Parent list id when inserting a listItem at list start (`after: null`). */
in?: string
}
| { t: 'removeBlock'; id: string }
| {
t: 'setBlockType'
id: string
type: 'paragraph' | 'heading' | 'blockquote' | 'list'
attrs?: HeadingAttrs | ListAttrs
/** New wrapper-list id for the `→ list` conversion (the container keeps its id as the listItem). */
newId?: string
}
| { t: 'setAttrs'; id: string; attrs: HeadingAttrs | ListAttrs }Steps — the serializable, invertible mutation primitives (spec §2).