Files
WRNexusJS/packages/blocks/src/commands.ts
T
Clintchiz edccf06658
Quality / quality (ubuntu-latest) (push) Failing after 10m0s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(builder): correct move and sortable behavior
2026-09-05 10:49:33 +05:30

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);
}