/** * @factory/editor — the block tree ↔ document codec (TASK-0047 R3). * * The canonical editor document is a nested object in which every block is * one `factoryBlock` node carrying `{id, position, type, content, properties}` * as attrs and child blocks as `content`. The codec is: * * - **pure** — no I/O, no DOM, no tiptap; * - **deterministic** — siblings are always ordered by fractional * `position` (`comparePositions` from `@factory/shared/positions`); * - **lossless** — `docToBlocks(blocksToDoc(forest))` reproduces the * forest's ids, parent links, positions, content and properties * byte-identically (unit-tested as a property test). * * The document shape is ProseMirror-compatible (so a real `ProseMirror.Doc` * built from it round-trips through `doc.toJSON()`), but this module never * imports ProseMirror — it operates on the JSON representation. * * Persistence note: `docToBlocks` returns the canonical block set (not ops); * the wire op set for autosave is produced by {@link diffBlocks} (insert / * update / move / delete), which is what the 800 ms autosave batch emits. */ import { comparePositions, type Position } from "@factory/shared/positions"; import type { BlockContentByType, BlockType } from "@factory/shared/dto"; import type { BlockDto, EditorBlockOperation, NewBlock } from "./types.js"; /** A node of the canonical (JSON) document. */ export interface FactoryDocNode { readonly type: string; readonly attrs?: Readonly>; readonly content?: readonly FactoryDocNode[]; } /** The canonical document root. */ export interface FactoryDoc { readonly type: "doc"; readonly content: readonly FactoryDocNode[]; } /** Node type name for every block in the canonical document. */ export const BLOCK_NODE_TYPE = "factoryBlock"; /** * Generate a client-side block id. The server assigns ids on insert; the * client only mints ids for optimistic local state and pasted external * content (TASK-0047 R2 — never regenerated for an existing block). */ export function generateBlockId(): string { // Client-local optimistic ids only (the server assigns authoritative // ids on insert — TASK-0047 R2). 32 hex chars from the PRNG; no crypto // dependency (this module is pure + node-testable). let suffix = ""; for (let i = 0; i < 32; i += 1) { suffix += Math.floor(Math.random() * 16).toString(16); } return `blk_${suffix}`; } /** Sibling sort key: fractional position (never array index, ADR-002). */ function byPosition(a: { position: Position }, b: { position: Position }): number { return comparePositions(a.position, b.position); } function isDoc(node: unknown): node is FactoryDoc { return ( typeof node === "object" && node !== null && (node as { type?: unknown }).type === "doc" && Array.isArray((node as { content?: unknown }).content) ); } function isBlockNode(node: unknown): node is FactoryDocNode { return ( typeof node === "object" && node !== null && (node as { type?: unknown }).type === BLOCK_NODE_TYPE ); } function attrsOf(node: FactoryDocNode): Record { return (node.attrs ?? {}) as Record; } /** * Build the canonical nested document from a flat block forest. * * Siblings are sorted by `position`; children nest under their parent's * `content`. Throws on duplicate ids or a block whose parent is missing * (invalid input — fail loud rather than guess). */ export function blocksToDoc(blocks: readonly BlockDto[]): FactoryDoc { const seen = new Set(); for (const block of blocks) { if (seen.has(block.id)) { throw new Error(`blocksToDoc: duplicate block id "${block.id}"`); } seen.add(block.id); } const childrenByParent = new Map(); for (const block of blocks) { const parent = block.parentId; if (parent !== null && !seen.has(parent)) { throw new Error( `blocksToDoc: block "${block.id}" references missing parent "${parent}"`, ); } const list = childrenByParent.get(parent) ?? []; list.push(block); childrenByParent.set(parent, list); } const toNode = (block: BlockDto): FactoryDocNode => { const attrs: Record = { id: block.id, position: block.position, type: block.type, content: block.content, }; if (block.properties !== undefined) { attrs.properties = block.properties; } const children = (childrenByParent.get(block.id) ?? []).slice().sort(byPosition); // FactoryDocNode is fully readonly — build the complete literal (a // `node.content = …` assignment is not allowed on it). const node: FactoryDocNode = { type: BLOCK_NODE_TYPE, attrs, ...(children.length > 0 ? { content: children.map(toNode) } : {}), }; return node; }; const roots = (childrenByParent.get(null) ?? []).slice().sort(byPosition); return { type: "doc", content: roots.map(toNode) }; } /** * Flatten the canonical document back to the block forest (DFS, children * immediately after their parent, siblings in `position` order). Unknown * node types throw (a Factory document contains only `factoryBlock`). */ export function docToBlocks(doc: unknown): BlockLike[] { if (!isDoc(doc)) { throw new Error("docToBlocks: expected a {type: 'doc', content: []} document"); } const out: BlockLike[] = []; const walk = (nodes: readonly FactoryDocNode[] | undefined, parentId: string | null): void => { for (const node of nodes ?? []) { if (!isBlockNode(node)) { throw new Error(`docToBlocks: unexpected node type "${String((node as { type?: unknown }).type)}"`); } const attrs = attrsOf(node); const type = attrs.type as BlockType | undefined; const position = attrs.position as Position | undefined; const content = attrs.content as BlockContentByType[BlockType] | undefined; const id = attrs.id as string | undefined; if (type === undefined || position === undefined || content === undefined || id === undefined) { throw new Error( `docToBlocks: block node "${String(attrs.id)}" is missing id/type/position/content attrs`, ); } const properties = attrs.properties as Record | undefined; // Returns id-bearing blocks (BlockLike, not NewBlock): the codec's // lossless contract reproduces the forest's ids — the wire op set // (toOperations) is what drops the id (insert carries no id). const block: BlockLike = { id, type, content, parentId, position, ...(properties !== undefined ? { properties } : {}), }; out.push(block); walk(node.content, id); } }; walk(doc.content, null); return out; } /** Deep, deterministic equality for plain-JSON values (content/properties). */ export function jsonEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") { return false; } const ka = Object.keys(a as Record).sort(); const kb = Object.keys(b as Record).sort(); if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false; return ka.every((k) => jsonEqual( (a as Record)[k], (b as Record)[k], ), ); } export interface BlockDiff { readonly deletes: readonly string[]; readonly updates: readonly { readonly id: string; readonly patch: { readonly content?: Record; readonly properties?: Record; }; }[]; readonly moves: readonly { readonly id: string; readonly before?: string; readonly after?: string; readonly toPageId?: string; }[]; readonly inserts: readonly BlockLike[]; /** Deletes + updates + moves + inserts, in send order (see {@link toOperations}). */ readonly operations: EditorBlockOperation[]; } type BlockLike = Pick; /** * Minimal op diff between two same-page block sets, in the exact wire * format of `@factory/shared`'s `blockOperationSchema` * (`insert | update | move | delete`). * * - `move.before` / `move.after` are sibling ids in the *destination* order * (at most one — the wire contract); the parent is implicit from the * anchor sibling. (Wire gap, recorded: the shared `moveOp` has no * `parentId` field, so a move into an *empty* new parent cannot be * anchored — see the package README; the API's `move target.parentId` * variant is not modeled by the shared DTO yet.) * - A block may appear in both `updates` and `moves` (content and placement * changed) — independent, both idempotent. * - Send order: deletes → updates → moves → inserts (inserts last so their * `before`/`after` anchors exist after the moves applied). */ export function diffBlocks( previous: readonly BlockLike[], next: readonly BlockLike[], ): BlockDiff { const prevById = new Map(); for (const b of previous) prevById.set(b.id, b); const nextById = new Map(); for (const b of next) nextById.set(b.id, b); const deletes = previous.filter((b) => !nextById.has(b.id)).map((b) => b.id); const inserts = next.filter((b) => !prevById.has(b.id)); const updates: { id: string; patch: { content?: Record; properties?: Record }; }[] = []; const moves: { id: string; before?: string; after?: string; toPageId?: string }[] = []; // Sibling order in `next` (per parent) → before/after anchor ids. const byParent = new Map(); for (const b of next) { const list = byParent.get(b.parentId) ?? []; list.push(b); byParent.set(b.parentId, list); } for (const list of byParent.values()) list.sort(byPosition); for (const [id, nb] of nextById) { const pb = prevById.get(id); if (pb === undefined) continue; const contentChanged = !jsonEqual(pb.content, nb.content); const propertiesChanged = (pb.properties === undefined) !== (nb.properties === undefined) || (pb.properties !== undefined && nb.properties !== undefined && !jsonEqual(pb.properties, nb.properties)); if (contentChanged || propertiesChanged) { const patch: { content?: Record; properties?: Record } = {}; if (contentChanged) patch.content = nb.content as Record; if (propertiesChanged && nb.properties !== undefined) patch.properties = nb.properties; updates.push({ id, patch }); } const moved = pb.position !== nb.position || pb.parentId !== nb.parentId; if (moved) { const siblings = byParent.get(nb.parentId) ?? []; const idx = siblings.findIndex((s) => s.id === id); const before = idx > 0 ? siblings[idx - 1]?.id : undefined; const after = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1]?.id : undefined; // A cross-parent move with no anchors on the destination side is // still a move (the server resolves end-of-list for that parent). if (before !== undefined || after !== undefined || pb.parentId !== nb.parentId) { const move: { id: string; before?: string; after?: string } = { id }; if (before !== undefined) move.before = before; if (after !== undefined) move.after = after; moves.push(move); } } } return { deletes, updates, moves, inserts, operations: toOperations(deletes, updates, moves, inserts) }; } /** Assemble the diff into wire `BlockOperation`s (the autosave batch body). */ export function toOperations( deletes: readonly string[], updates: readonly { readonly id: string; readonly patch: { readonly content?: Record; readonly properties?: Record; }; }[], moves: readonly { readonly id: string; readonly before?: string; readonly after?: string; readonly toPageId?: string; }[], // Accepts both the id-bearing `BlockLike` (from diffBlocks.inserts) and // the id-less `NewBlock` (a client-built insert); the wire op drops the id. inserts: readonly (NewBlock | BlockLike)[], ): EditorBlockOperation[] { const ops: EditorBlockOperation[] = []; for (const id of deletes) ops.push({ op: "delete", id }); for (const u of updates) ops.push({ op: "update", id: u.id, patch: u.patch }); for (const m of moves) { // Wire contract: at most one of before/after (the shared // blockOperationSchema's superRefine). Prefer the `before` anchor; // fall back to `after` when the block leads its destination siblings. const move: { op: "move"; id: string; before?: string; after?: string } = { op: "move", id: m.id }; if (m.before !== undefined) move.before = m.before; else if (m.after !== undefined) move.after = m.after; ops.push(move); } for (const b of inserts) { const block: { op: "insert"; block: { type: BlockType; content: Record; properties?: Record; parentId?: string; position: Position; }; } = { op: "insert", block: { type: b.type, content: b.content as Record, position: b.position, }, }; if (b.properties !== undefined) block.block.properties = b.properties; if (b.parentId !== null) block.block.parentId = b.parentId; ops.push(block); } return ops; }