import { findBlock, validateDocument } from "./document.ts"; import type { DocumentKindDefinition } from "./kinds.ts"; import type { ReadonlyBlockRegistry } from "./registry.ts"; import type { BlockDocument, BlockLocation, BlockNode, BlockStyles, Diagnostic, JsonObject, MoveIntent, Result, SelectionState, } from "./types.ts"; export type BlockCommand = | { type: "insert"; location: BlockLocation; nodes: BlockNode[] } | { type: "remove"; nodeIds: string[] } | { type: "move"; intent: MoveIntent } | { type: "duplicate"; nodeIds: string[]; ids: string[] } | { type: "updateProps"; nodeId: string; patch: JsonObject } | { type: "updateStyles"; nodeId: string; patch: BlockStyles } | { type: "replace"; nodeId: string; node: BlockNode } | { type: "batch"; commands: BlockCommand[] }; export interface CommandResult { document: BlockDocument; inverse: BlockCommand; affectedIds: string[]; selection: SelectionState; diagnostics: Diagnostic[]; } const clone = (value: T): T => structuredClone(value); function updateNode( root: BlockNode, id: string, update: (node: BlockNode) => BlockNode, ): BlockNode { if (root.id === id) return update(root); const slots: Record = {}; for (const [name, children] of Object.entries(root.slots)) slots[name] = children.map((child) => updateNode(child, id, update)); return { ...root, slots }; } function locationOf(root: BlockNode, id: string): BlockLocation | undefined { for (const [slot, children] of Object.entries(root.slots)) { const index = children.findIndex((child) => child.id === id); if (index >= 0) return { parentId: root.id, slot, index }; for (const child of children) { const nested = locationOf(child, id); if (nested) return nested; } } } function insert(root: BlockNode, location: BlockLocation, nodes: BlockNode[]): BlockNode { return updateNode(root, location.parentId, (parent) => { const children = [...(parent.slots[location.slot] ?? [])]; children.splice(location.index, 0, ...nodes); return { ...parent, slots: { ...parent.slots, [location.slot]: children } }; }); } function remove(root: BlockNode, ids: Set): BlockNode { const slots: Record = {}; for (const [name, children] of Object.entries(root.slots)) slots[name] = children.filter((child) => !ids.has(child.id)).map((child) => remove(child, ids)); return { ...root, slots }; } export function applyCommand( document: BlockDocument, command: BlockCommand, kind: DocumentKindDefinition, registry: ReadonlyBlockRegistry, ): Result { if (command.type === "batch") { let current = document; const inverses: BlockCommand[] = []; const affected: string[] = []; for (const child of command.commands) { const result = applyCommand(current, child, kind, registry); if (!result.ok) return result; current = result.value.document; inverses.unshift(result.value.inverse); affected.push(...result.value.affectedIds); } return { ok: true, value: { document: current, inverse: { type: "batch", commands: inverses }, affectedIds: [...new Set(affected)], selection: { primary: affected.at(-1) ?? null, ordered: [...new Set(affected)] }, diagnostics: [], }, }; } let root = document.root; let inverse: BlockCommand; let affected: string[]; if (command.type === "insert") { root = insert(root, command.location, clone(command.nodes)); affected = command.nodes.map((node) => node.id); inverse = { type: "remove", nodeIds: affected }; } else if (command.type === "remove") { const captures = command.nodeIds.map((id) => ({ node: findBlock(root, id), location: locationOf(root, id), })); if (captures.some((item) => !item.node || !item.location)) return { ok: false, errors: [ { code: "command.missing", message: "Cannot remove the root or a missing block.", severity: "error", }, ], }; root = remove(root, new Set(command.nodeIds)); affected = command.nodeIds; inverse = { type: "batch", commands: captures.map((item) => ({ type: "insert", location: item.location!, nodes: [item.node!], })), }; } else if (command.type === "move") { const moving = command.intent.nodeIds.map((id) => findBlock(root, id)); if (moving.some((node) => !node)) return { ok: false, errors: [ { code: "command.missing", message: "A moved block does not exist.", severity: "error" }, ], }; for (const node of moving) if (node && findBlock(node, command.intent.to.parentId)) return { ok: false, errors: [ { code: "command.cycle", message: "A block cannot move into its descendant.", severity: "error", }, ], }; const removedBeforeDestination = command.intent.from.filter( (location) => location.parentId === command.intent.to.parentId && location.slot === command.intent.to.slot && location.index < command.intent.to.index, ).length; root = insert( remove(root, new Set(command.intent.nodeIds)), { ...command.intent.to, index: Math.max(0, command.intent.to.index - removedBeforeDestination) }, moving as BlockNode[], ); affected = command.intent.nodeIds; inverse = { type: "batch", commands: command.intent.from.map((location, index) => ({ type: "move", intent: { nodeIds: [command.intent.nodeIds[index]!], from: [command.intent.to], to: location, }, })), }; } else if (command.type === "duplicate") { const originals = command.nodeIds.map((id) => findBlock(root, id)); const locations = command.nodeIds.map((id) => locationOf(root, id)); if (originals.some((node) => !node) || locations.some((location) => !location)) return { ok: false, errors: [{ code: "command.missing", message: "A duplicated block does not exist.", severity: "error" }] }; const countNodes = (node: BlockNode): number => 1 + Object.values(node.slots).flat().reduce((total, child) => total + countNodes(child), 0); const requiredIds = (originals as BlockNode[]).reduce( (total, node) => total + countNodes(node), 0, ); if (command.ids.length !== requiredIds || new Set(command.ids).size !== command.ids.length) return { ok: false, errors: [{ code: "command.ids", message: "Duplicate requires one unique id for every copied node.", severity: "error" }] }; const copies = duplicateNodes(originals as BlockNode[], (() => { let index = 0; return () => command.ids[index++]!; })()); const commands = copies.map((node, index) => ({ type: "insert" as const, location: { ...locations[index]!, index: locations[index]!.index + 1 }, nodes: [node], })); return applyCommand(document, { type: "batch", commands }, kind, registry); } else if (command.type === "updateProps") { const previous = findBlock(root, command.nodeId); if (!previous) return { ok: false, errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }], }; root = updateNode(root, command.nodeId, (node) => ({ ...node, props: { ...node.props, ...command.patch }, })); affected = [command.nodeId]; inverse = { type: "replace", nodeId: command.nodeId, node: clone(previous) }; } else if (command.type === "updateStyles") { const previous = findBlock(root, command.nodeId); if (!previous) return { ok: false, errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }], }; root = updateNode(root, command.nodeId, (node) => ({ ...node, styles: { ...node.styles, ...command.patch }, })); affected = [command.nodeId]; inverse = { type: "replace", nodeId: command.nodeId, node: clone(previous) }; } else { const previous = findBlock(root, command.nodeId); if (!previous) return { ok: false, errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }], }; root = updateNode(root, command.nodeId, () => clone(command.node)); affected = [command.node.id]; inverse = { type: "replace", nodeId: command.node.id, node: clone(previous) }; } const next = { ...document, root }; const valid = validateDocument(next, kind, registry); if (!valid.ok) return valid; return { ok: true, value: { document: next, inverse, affectedIds: affected, selection: { primary: affected[0] ?? null, ordered: affected }, diagnostics: [], }, }; } export function duplicateNodes(nodes: readonly BlockNode[], id: () => string): BlockNode[] { const copy = (node: BlockNode): BlockNode => ({ ...clone(node), id: id(), slots: Object.fromEntries( Object.entries(node.slots).map(([name, children]) => [name, children.map(copy)]), ), }); return nodes.map(copy); }