264 lines
9.1 KiB
TypeScript
264 lines
9.1 KiB
TypeScript
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 = <T>(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<string, BlockNode[]> = {};
|
|
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<string>): BlockNode {
|
|
const slots: Record<string, BlockNode[]> = {};
|
|
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<CommandResult> {
|
|
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);
|
|
}
|