mind-map/packages/graph-core/src/index.ts
oliver 2ecd300ada Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 08:53:37 +08:00

1017 lines
31 KiB
TypeScript

import type {
CanvasMeta,
CanvasPrefs,
MindEdge,
MindNode,
Op,
} from "@mind-map/shared";
import { newId, now } from "@mind-map/shared";
export type MutableGraph = {
canvas: CanvasMeta;
nodes: Map<string, MindNode>;
edges: Map<string, MindEdge>;
};
export type ApplyResult = {
graph: MutableGraph;
inverseOps: Op[];
affectedNodeIds: string[];
/** When set, host must write fitted viewport before/with persist. */
autoFitPadding?: number;
};
export class GraphError extends Error {
constructor(
public code:
| "not_found"
| "version_conflict"
| "invalid_op"
| "cycle_rejected",
message: string,
) {
super(message);
this.name = "GraphError";
}
}
function liveNodes(g: MutableGraph): MindNode[] {
return [...g.nodes.values()].filter((n) => !n.deletedAt);
}
function liveEdges(g: MutableGraph): MindEdge[] {
return [...g.edges.values()].filter((e) => !e.deletedAt);
}
function wouldCreateHierarchyCycle(
g: MutableGraph,
from: string,
to: string,
): boolean {
const children = new Map<string, string[]>();
for (const e of liveEdges(g)) {
if (e.kind !== "hierarchy") continue;
const list = children.get(e.from) ?? [];
list.push(e.to);
children.set(e.from, list);
}
const stack = [to];
const seen = new Set<string>();
while (stack.length) {
const cur = stack.pop()!;
if (cur === from) return true;
if (seen.has(cur)) continue;
seen.add(cur);
for (const c of children.get(cur) ?? []) stack.push(c);
}
return false;
}
function clearPrimaryForTarget(g: MutableGraph, to: string): void {
for (const e of liveEdges(g)) {
if (e.kind === "hierarchy" && e.to === to && e.isPrimaryParent) {
e.isPrimaryParent = false;
e.version += 1;
e.updatedAt = now();
}
}
}
function promoteChildrenOnDelete(g: MutableGraph, nodeId: string): Op[] {
const inverse: Op[] = [];
const node = g.nodes.get(nodeId);
if (!node) return inverse;
let primaryParentId: string | null = null;
for (const e of liveEdges(g)) {
if (e.kind === "hierarchy" && e.to === nodeId && e.isPrimaryParent) {
primaryParentId = e.from;
break;
}
}
const childEdges = liveEdges(g).filter(
(e) => e.kind === "hierarchy" && e.from === nodeId,
);
for (const ce of childEdges) {
ce.deletedAt = now();
ce.version += 1;
inverse.push({ type: "link", from: nodeId, to: ce.to, kind: "hierarchy", asPrimary: ce.isPrimaryParent, label: ce.label });
if (primaryParentId) {
const id = newId("e");
const t = now();
clearPrimaryForTarget(g, ce.to);
g.edges.set(id, {
id,
canvasId: g.canvas.id,
from: primaryParentId,
to: ce.to,
kind: "hierarchy",
isPrimaryParent: true,
order: ce.order,
tags: [],
version: 1,
createdAt: t,
updatedAt: t,
});
inverse.push({ type: "unlink", edgeId: id });
}
}
return inverse;
}
export function createEmptyGraph(
title = "未命名画布",
rootText?: string,
): MutableGraph {
const canvasId = newId("c");
const nodeId = newId("n");
const t = now();
const prefs: CanvasPrefs = {
themeId: "abyss",
branchColoring: true,
density: "comfortable",
riskProfile: "fast",
showRelationEdges: true,
nodeBadges: "icons",
showHoverCard: true,
inspectorMode: "selection",
nodeViewMode: "card",
};
const canvas: CanvasMeta = {
id: canvasId,
title,
focusNodeId: nodeId,
anchorNodeId: nodeId,
viewport: { x: 0, y: 0, zoom: 1 },
rev: 0,
prefs,
agentMode: "edit",
createdAt: t,
updatedAt: t,
};
const node: MindNode = {
id: nodeId,
canvasId,
text: rootText ?? title,
collapsed: false,
pinned: false,
sidePref: 0,
stylePreset: "title",
version: 1,
tags: [],
links: [],
createdAt: t,
updatedAt: t,
};
return {
canvas,
nodes: new Map([[nodeId, node]]),
edges: new Map(),
};
}
export function resolveAnchorNodeId(g: MutableGraph): string | null {
if (g.canvas.anchorNodeId && g.nodes.get(g.canvas.anchorNodeId) && !g.nodes.get(g.canvas.anchorNodeId)!.deletedAt) {
return g.canvas.anchorNodeId;
}
for (const n of g.nodes.values()) {
if (n.deletedAt) continue;
if (n.stylePreset !== "title") continue;
const hasParent = [...g.edges.values()].some(
(e) =>
e.kind === "hierarchy" &&
e.isPrimaryParent &&
!e.deletedAt &&
e.to === n.id,
);
if (!hasParent) {
g.canvas.anchorNodeId = n.id;
return n.id;
}
}
return g.canvas.focusNodeId;
}
export function isAnchorNode(g: MutableGraph, nodeId: string): boolean {
return resolveAnchorNodeId(g) === nodeId;
}
export function cloneGraph(g: MutableGraph): MutableGraph {
return {
canvas: { ...g.canvas, prefs: { ...g.canvas.prefs }, viewport: { ...g.canvas.viewport } },
nodes: new Map(
[...g.nodes.entries()].map(([k, v]) => [
k,
{
...v,
tags: [...(v.tags ?? [])],
links: (v.links ?? []).map((l) => ({ ...l })),
description: v.description ? { ...v.description } : undefined,
},
]),
),
edges: new Map(
[...g.edges.entries()].map(([k, v]) => [
k,
{ ...v, tags: [...(v.tags ?? [])] },
]),
),
};
}
function assertAliasUnique(
g: MutableGraph,
alias: string | undefined,
selfId: string,
) {
if (!alias) return;
for (const other of g.nodes.values()) {
if (!other.deletedAt && other.id !== selfId && other.alias === alias) {
throw new GraphError(
"invalid_op",
`alias "${alias}" already used by ${other.id}`,
);
}
}
}
function addOutgoingRelations(
g: MutableGraph,
fromId: string,
relations:
| {
to: string;
kind?: "hierarchy" | "relation";
label?: string;
weight?: number;
lineStyle?: MindEdge["lineStyle"];
direction?: MindEdge["direction"];
}[]
| undefined,
affected: Set<string>,
) {
if (!relations?.length) return;
const t = now();
for (const rel of relations) {
if (!g.nodes.get(rel.to) || g.nodes.get(rel.to)!.deletedAt) {
throw new GraphError("not_found", rel.to);
}
const kind = rel.kind ?? "relation";
if (kind === "hierarchy" && wouldCreateHierarchyCycle(g, fromId, rel.to)) {
throw new GraphError("cycle_rejected", "hierarchy cycle");
}
const eid = newId("e");
if (kind === "hierarchy") clearPrimaryForTarget(g, rel.to);
g.edges.set(eid, {
id: eid,
canvasId: g.canvas.id,
from: fromId,
to: rel.to,
kind,
isPrimaryParent: kind === "hierarchy",
order: 0,
label: rel.label,
tags: [],
weight: rel.weight,
lineStyle: rel.lineStyle,
direction: rel.direction ?? (kind === "relation" ? "forward" : "none"),
version: 1,
createdAt: t,
updatedAt: t,
});
// Inverse is covered by delete_node of the source (same as hierarchy parent edge).
affected.add(fromId);
affected.add(rel.to);
}
}
function materializeNodeFromCreate(
g: MutableGraph,
op: Extract<Op, { type: "create_node" }> | {
id?: string;
text: string;
parentId?: string | null;
sidePref?: -1 | 0 | 1;
stylePreset?: MindNode["stylePreset"];
pos?: { x: number; y: number };
pinned?: boolean;
note?: string;
description?: MindNode["description"];
tags?: string[];
links?: MindNode["links"];
imageUrl?: string;
dueAt?: number;
startAt?: number;
accentColor?: MindNode["accentColor"];
icon?: MindNode["icon"];
alias?: string;
},
opts: { attachParent: boolean; anchorId: string | null },
): { id: string; parentId: string | null } {
const id = op.id ?? newId("n");
if (g.nodes.has(id) && !g.nodes.get(id)!.deletedAt) {
throw new GraphError("invalid_op", `node exists: ${id}`);
}
if (op.stylePreset === "title") {
throw new GraphError(
"invalid_op",
"Cannot create another title/anchor node; omit parentId for top-level content nodes",
);
}
const t = now();
const pinPos = op.pos;
const pinned = op.pinned ?? Boolean(pinPos);
const alias =
op.alias?.trim().replace(/^@/, "").toLowerCase() || undefined;
assertAliasUnique(g, alias, id);
const node: MindNode = {
id,
canvasId: g.canvas.id,
text: op.text,
note: op.note,
description: op.description ? { ...op.description } : undefined,
collapsed: false,
pinned,
pos: pinPos ?? null,
sidePref: op.sidePref ?? 0,
stylePreset: op.stylePreset ?? "default",
version: 1,
tags: op.tags ? [...op.tags] : [],
links: op.links ? op.links.map((l) => ({ ...l })) : [],
imageUrl: op.imageUrl,
dueAt: op.dueAt,
startAt: op.startAt,
accentColor: op.accentColor,
icon: op.icon,
alias,
createdAt: t,
updatedAt: t,
};
g.nodes.set(id, node);
let parentId: string | null =
op.parentId === undefined || op.parentId === null || op.parentId === ""
? opts.anchorId
: op.parentId;
if (!opts.attachParent) {
return { id, parentId };
}
if (parentId === id) {
throw new GraphError("invalid_op", "node cannot parent itself");
}
if (parentId) {
if (!g.nodes.get(parentId) || g.nodes.get(parentId)!.deletedAt) {
throw new GraphError("not_found", `parent ${parentId}`);
}
if (wouldCreateHierarchyCycle(g, parentId, id)) {
throw new GraphError("cycle_rejected", "hierarchy cycle");
}
const eid = newId("e");
clearPrimaryForTarget(g, id);
g.edges.set(eid, {
id: eid,
canvasId: g.canvas.id,
from: parentId,
to: id,
kind: "hierarchy",
isPrimaryParent: true,
order: 0,
tags: [],
version: 1,
createdAt: t,
updatedAt: t,
});
}
return { id, parentId };
}
export function applyOps(graph: MutableGraph, ops: Op[]): ApplyResult {
const g = cloneGraph(graph);
const inverseOps: Op[] = [];
const affected = new Set<string>();
const anchorId = resolveAnchorNodeId(g);
let autoFitPadding: number | undefined;
for (const op of ops) {
switch (op.type) {
case "create_node": {
const { id, parentId } = materializeNodeFromCreate(g, op, {
attachParent: true,
anchorId,
});
affected.add(id);
if (parentId) affected.add(parentId);
inverseOps.push({ type: "delete_node", nodeId: id });
addOutgoingRelations(g, id, op.relations, affected);
break;
}
case "batch_create": {
const specs = op.nodes;
const created: {
id: string;
parentId: string | null;
relations?: (typeof specs)[0]["relations"];
}[] = [];
for (const spec of specs) {
const { id, parentId } = materializeNodeFromCreate(g, spec, {
attachParent: false,
anchorId,
});
created.push({ id, parentId, relations: spec.relations });
affected.add(id);
inverseOps.push({ type: "delete_node", nodeId: id });
}
const t = now();
for (const c of created) {
let parentId = c.parentId;
if (parentId === undefined || parentId === null || parentId === "") {
parentId = anchorId;
}
if (!parentId || parentId === c.id) continue;
if (!g.nodes.get(parentId) || g.nodes.get(parentId)!.deletedAt) {
throw new GraphError("not_found", `parent ${parentId}`);
}
if (wouldCreateHierarchyCycle(g, parentId, c.id)) {
throw new GraphError("cycle_rejected", "hierarchy cycle");
}
const eid = newId("e");
clearPrimaryForTarget(g, c.id);
g.edges.set(eid, {
id: eid,
canvasId: g.canvas.id,
from: parentId,
to: c.id,
kind: "hierarchy",
isPrimaryParent: true,
order: 0,
tags: [],
version: 1,
createdAt: t,
updatedAt: t,
});
affected.add(parentId);
}
for (const c of created) {
addOutgoingRelations(g, c.id, c.relations, affected);
}
break;
}
case "batch_link": {
for (const edge of op.edges) {
if (!g.nodes.get(edge.from) || g.nodes.get(edge.from)!.deletedAt) {
throw new GraphError("not_found", edge.from);
}
if (!g.nodes.get(edge.to) || g.nodes.get(edge.to)!.deletedAt) {
throw new GraphError("not_found", edge.to);
}
if (
edge.kind === "hierarchy" &&
wouldCreateHierarchyCycle(g, edge.from, edge.to)
) {
throw new GraphError("cycle_rejected", "hierarchy cycle");
}
const eid = newId("e");
const t = now();
if (edge.kind === "hierarchy" && edge.asPrimary !== false) {
clearPrimaryForTarget(g, edge.to);
}
g.edges.set(eid, {
id: eid,
canvasId: g.canvas.id,
from: edge.from,
to: edge.to,
kind: edge.kind,
isPrimaryParent:
edge.kind === "hierarchy" && edge.asPrimary !== false,
order: 0,
label: edge.label,
tags: [],
weight: edge.weight,
lineStyle: edge.lineStyle,
direction:
edge.direction ??
(edge.kind === "relation" ? "forward" : "none"),
version: 1,
createdAt: t,
updatedAt: t,
});
inverseOps.push({ type: "unlink", edgeId: eid });
affected.add(edge.from);
affected.add(edge.to);
}
break;
}
case "update_node_meta": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
if (op.expectedVersion != null && n.version !== op.expectedVersion) {
throw new GraphError("version_conflict", op.nodeId);
}
const prevPatch = {
note: n.note ?? null,
description: n.description ? { ...n.description } : null,
tags: [...(n.tags ?? [])],
links: (n.links ?? []).map((l) => ({ ...l })),
imageUrl: n.imageUrl ?? null,
dueAt: n.dueAt ?? null,
startAt: n.startAt ?? null,
accentColor: n.accentColor ?? null,
icon: n.icon ?? null,
alias: n.alias ?? null,
};
inverseOps.push({
type: "update_node_meta",
nodeId: op.nodeId,
patch: prevPatch,
expectedVersion: n.version + 1,
});
const p = op.patch;
if (p.note !== undefined) n.note = p.note ?? undefined;
if (p.description !== undefined) {
n.description =
p.description == null ? undefined : { ...p.description };
}
if (p.tags !== undefined) n.tags = [...p.tags];
if (p.links !== undefined) n.links = p.links.map((l) => ({ ...l }));
if (p.imageUrl !== undefined) n.imageUrl = p.imageUrl ?? undefined;
if (p.dueAt !== undefined) n.dueAt = p.dueAt ?? undefined;
if (p.startAt !== undefined) n.startAt = p.startAt ?? undefined;
if (p.accentColor !== undefined) n.accentColor = p.accentColor ?? undefined;
if (p.icon !== undefined) n.icon = p.icon ?? undefined;
if (p.alias !== undefined) {
const next =
p.alias == null
? undefined
: p.alias.trim().replace(/^@/, "").toLowerCase();
assertAliasUnique(g, next, n.id);
n.alias = next;
}
n.version += 1;
n.updatedAt = now();
affected.add(n.id);
break;
}
case "update_text": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
if (op.expectedVersion != null && n.version !== op.expectedVersion) {
throw new GraphError("version_conflict", op.nodeId);
}
inverseOps.push({
type: "update_text",
nodeId: op.nodeId,
text: n.text,
expectedVersion: n.version + 1,
});
n.text = op.text;
n.version += 1;
n.updatedAt = now();
if (isAnchorNode(g, op.nodeId)) {
g.canvas.title = op.text;
}
affected.add(n.id);
break;
}
case "move_node": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
if (isAnchorNode(g, op.nodeId)) {
throw new GraphError("invalid_op", "Cannot move the hidden canvas anchor");
}
const oldPrimary = liveEdges(g).find(
(e) => e.kind === "hierarchy" && e.to === op.nodeId && e.isPrimaryParent,
);
if (oldPrimary) {
oldPrimary.deletedAt = now();
oldPrimary.version += 1;
inverseOps.push({
type: "link",
from: oldPrimary.from,
to: oldPrimary.to,
kind: "hierarchy",
asPrimary: true,
label: oldPrimary.label,
});
}
if (op.newParentId || anchorId) {
const newParentId = op.newParentId || anchorId!;
if (!g.nodes.get(newParentId) || g.nodes.get(newParentId)!.deletedAt) {
throw new GraphError("not_found", newParentId);
}
if (wouldCreateHierarchyCycle(g, newParentId, op.nodeId)) {
throw new GraphError("cycle_rejected", "hierarchy cycle");
}
const eid = newId("e");
const t = now();
clearPrimaryForTarget(g, op.nodeId);
g.edges.set(eid, {
id: eid,
canvasId: g.canvas.id,
from: newParentId,
to: op.nodeId,
kind: "hierarchy",
isPrimaryParent: true,
order: op.order ?? 0,
tags: [],
version: 1,
createdAt: t,
updatedAt: t,
});
inverseOps.push({ type: "unlink", edgeId: eid });
affected.add(newParentId);
}
if (op.sidePref != null) n.sidePref = op.sidePref;
n.version += 1;
n.updatedAt = now();
affected.add(n.id);
break;
}
case "reorder": {
const e = liveEdges(g).find(
(x) => x.kind === "hierarchy" && x.to === op.nodeId && x.isPrimaryParent,
);
if (!e) throw new GraphError("not_found", `primary edge for ${op.nodeId}`);
if (op.order == null && op.afterSiblingId === undefined) {
throw new GraphError(
"invalid_op",
"reorder requires order (number) or afterSiblingId",
);
}
let nextOrder = op.order;
if (nextOrder == null) {
const siblings = liveEdges(g)
.filter(
(x) =>
x.kind === "hierarchy" &&
x.isPrimaryParent &&
x.from === e.from &&
x.to !== op.nodeId,
)
.sort((a, b) => a.order - b.order);
if (op.afterSiblingId == null) {
nextOrder = (siblings[0]?.order ?? 0) - 1;
} else {
const after = siblings.find((s) => s.to === op.afterSiblingId);
if (!after) {
throw new GraphError(
"not_found",
`afterSiblingId ${op.afterSiblingId} not under same parent`,
);
}
const idx = siblings.indexOf(after);
const next = siblings[idx + 1];
nextOrder = next
? (after.order + next.order) / 2
: after.order + 1;
}
}
const n = g.nodes.get(op.nodeId)!;
// Pinned x/y would ignore sibling order — unpin so re-layout can move it.
if (n.pinned) {
inverseOps.push({
type: "set_pinned",
nodeId: op.nodeId,
pinned: true,
pos: n.pos ?? undefined,
});
n.pinned = false;
n.pos = null;
}
inverseOps.push({ type: "reorder", nodeId: op.nodeId, order: e.order });
e.order = nextOrder;
e.version += 1;
e.updatedAt = now();
n.version += 1;
n.updatedAt = now();
affected.add(op.nodeId);
break;
}
case "set_primary_parent": {
const edge = g.edges.get(op.edgeId);
if (!edge || edge.deletedAt || edge.kind !== "hierarchy") {
throw new GraphError("not_found", op.edgeId);
}
if (edge.to !== op.nodeId) {
throw new GraphError("invalid_op", "edge target mismatch");
}
const prev = liveEdges(g).find(
(e) => e.kind === "hierarchy" && e.to === op.nodeId && e.isPrimaryParent,
);
if (prev) {
inverseOps.push({
type: "set_primary_parent",
nodeId: op.nodeId,
edgeId: prev.id,
});
prev.isPrimaryParent = false;
prev.version += 1;
}
edge.isPrimaryParent = true;
edge.version += 1;
edge.updatedAt = now();
affected.add(op.nodeId);
break;
}
case "link": {
if (!g.nodes.get(op.from) || g.nodes.get(op.from)!.deletedAt) {
throw new GraphError("not_found", op.from);
}
if (!g.nodes.get(op.to) || g.nodes.get(op.to)!.deletedAt) {
throw new GraphError("not_found", op.to);
}
if (op.kind === "hierarchy" && wouldCreateHierarchyCycle(g, op.from, op.to)) {
throw new GraphError("cycle_rejected", "hierarchy cycle");
}
const eid = newId("e");
const t = now();
if (op.kind === "hierarchy" && op.asPrimary !== false) {
clearPrimaryForTarget(g, op.to);
}
g.edges.set(eid, {
id: eid,
canvasId: g.canvas.id,
from: op.from,
to: op.to,
kind: op.kind,
isPrimaryParent: op.kind === "hierarchy" && op.asPrimary !== false,
order: 0,
label: op.label,
tags: [],
weight: op.weight,
lineStyle: op.lineStyle,
direction:
op.direction ?? (op.kind === "relation" ? "forward" : "none"),
version: 1,
createdAt: t,
updatedAt: t,
});
inverseOps.push({ type: "unlink", edgeId: eid });
affected.add(op.from);
affected.add(op.to);
break;
}
case "unlink": {
const e = g.edges.get(op.edgeId);
if (!e || e.deletedAt) throw new GraphError("not_found", op.edgeId);
inverseOps.push({
type: "link",
from: e.from,
to: e.to,
kind: e.kind,
asPrimary: e.isPrimaryParent,
label: e.label,
});
e.deletedAt = now();
e.version += 1;
affected.add(e.from);
affected.add(e.to);
break;
}
case "delete_node": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
if (isAnchorNode(g, op.nodeId)) {
throw new GraphError("invalid_op", "Cannot delete the hidden canvas anchor");
}
inverseOps.push(...promoteChildrenOnDelete(g, op.nodeId).reverse());
// Soft-delete remaining incident edges. restore_node undeletes them
// (with conflict checks) — do not push link inverses here or undo would duplicate.
for (const e of liveEdges(g)) {
if (e.from === op.nodeId || e.to === op.nodeId) {
e.deletedAt = now();
e.version += 1;
}
}
inverseOps.push({ type: "restore_node", nodeId: op.nodeId });
n.deletedAt = now();
n.version += 1;
n.updatedAt = now();
if (g.canvas.focusNodeId === op.nodeId) {
const parentEdge = [...g.edges.values()].find(
(e) =>
e.kind === "hierarchy" &&
e.to === op.nodeId &&
e.isPrimaryParent &&
e.deletedAt,
);
const fallback =
(parentEdge && liveNodes(g).find((x) => x.id === parentEdge.from)?.id) ||
liveNodes(g).sort((a, b) => b.updatedAt - a.updatedAt)[0]?.id ||
null;
if (!fallback) {
const anchor = createEmptyGraph(g.canvas.title);
const only = [...anchor.nodes.values()][0]!;
only.canvasId = g.canvas.id;
g.nodes.set(only.id, only);
g.canvas.focusNodeId = only.id;
affected.add(only.id);
} else {
g.canvas.focusNodeId = fallback;
affected.add(fallback);
}
}
affected.add(op.nodeId);
break;
}
case "restore_node": {
const n = g.nodes.get(op.nodeId);
if (!n) throw new GraphError("not_found", op.nodeId);
if (isAnchorNode(g, op.nodeId)) {
throw new GraphError("invalid_op", "Cannot restore the hidden canvas anchor");
}
if (!n.deletedAt) {
throw new GraphError(
"invalid_op",
`node ${op.nodeId} is not deleted`,
);
}
inverseOps.push({ type: "delete_node", nodeId: op.nodeId });
n.deletedAt = null;
n.version += 1;
n.updatedAt = now();
affected.add(n.id);
// Undelete soft-deleted incident edges when the other end is live and
// there is no conflicting live edge (e.g. children already promoted).
for (const e of g.edges.values()) {
if (!e.deletedAt) continue;
if (e.from !== op.nodeId && e.to !== op.nodeId) continue;
const otherId = e.from === op.nodeId ? e.to : e.from;
const other = g.nodes.get(otherId);
if (!other || other.deletedAt) continue;
const dup = liveEdges(g).some(
(x) =>
x.from === e.from && x.to === e.to && x.kind === e.kind,
);
if (dup) continue;
if (e.kind === "hierarchy" && e.isPrimaryParent) {
const hasPrimary = liveEdges(g).some(
(x) =>
x.kind === "hierarchy" &&
x.isPrimaryParent &&
x.to === e.to,
);
if (hasPrimary) continue;
}
e.deletedAt = null;
e.version += 1;
e.updatedAt = now();
affected.add(e.from);
affected.add(e.to);
}
break;
}
case "set_pinned": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
inverseOps.push({
type: "set_pinned",
nodeId: op.nodeId,
pinned: n.pinned,
pos: n.pos ?? undefined,
});
n.pinned = op.pinned;
if (op.pos) n.pos = op.pos;
if (!op.pinned) n.pos = null;
n.version += 1;
n.updatedAt = now();
affected.add(n.id);
break;
}
case "set_collapsed": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
inverseOps.push({
type: "set_collapsed",
nodeId: op.nodeId,
collapsed: n.collapsed,
});
n.collapsed = op.collapsed;
n.version += 1;
n.updatedAt = now();
affected.add(n.id);
break;
}
case "set_style_preset": {
const n = g.nodes.get(op.nodeId);
if (!n || n.deletedAt) throw new GraphError("not_found", op.nodeId);
inverseOps.push({
type: "set_style_preset",
nodeId: op.nodeId,
stylePreset: n.stylePreset,
});
n.stylePreset = op.stylePreset;
n.version += 1;
n.updatedAt = now();
affected.add(n.id);
break;
}
case "set_edge_label": {
const e = g.edges.get(op.edgeId);
if (!e || e.deletedAt) throw new GraphError("not_found", op.edgeId);
inverseOps.push({
type: "set_edge_label",
edgeId: op.edgeId,
label: e.label ?? "",
});
e.label = op.label;
e.version += 1;
e.updatedAt = now();
affected.add(e.from);
affected.add(e.to);
break;
}
case "update_edge_meta": {
const e = g.edges.get(op.edgeId);
if (!e || e.deletedAt) throw new GraphError("not_found", op.edgeId);
if (op.expectedVersion != null && e.version !== op.expectedVersion) {
throw new GraphError("version_conflict", op.edgeId);
}
inverseOps.push({
type: "update_edge_meta",
edgeId: op.edgeId,
patch: {
label: e.label ?? null,
tags: [...(e.tags ?? [])],
note: e.note ?? null,
weight: e.weight ?? null,
lineStyle: e.lineStyle ?? null,
direction: e.direction ?? null,
},
expectedVersion: e.version + 1,
});
const p = op.patch;
if (p.label !== undefined) e.label = p.label ?? undefined;
if (p.tags !== undefined) e.tags = [...p.tags];
if (p.note !== undefined) e.note = p.note ?? undefined;
if (p.weight !== undefined) e.weight = p.weight ?? undefined;
if (p.lineStyle !== undefined) e.lineStyle = p.lineStyle ?? undefined;
if (p.direction !== undefined) e.direction = p.direction ?? undefined;
e.version += 1;
e.updatedAt = now();
affected.add(e.from);
affected.add(e.to);
break;
}
case "set_prefs": {
const prev = { ...g.canvas.prefs };
inverseOps.push({ type: "set_prefs", prefs: prev });
g.canvas.prefs = { ...g.canvas.prefs, ...op.prefs } as CanvasPrefs;
g.canvas.updatedAt = now();
break;
}
case "set_focus": {
if (!g.nodes.get(op.nodeId) || g.nodes.get(op.nodeId)!.deletedAt) {
throw new GraphError("not_found", op.nodeId);
}
const prev = g.canvas.focusNodeId;
if (prev) inverseOps.push({ type: "set_focus", nodeId: prev });
g.canvas.focusNodeId = op.nodeId;
g.canvas.updatedAt = now();
affected.add(op.nodeId);
break;
}
case "set_viewport": {
inverseOps.push({
type: "set_viewport",
viewport: { ...g.canvas.viewport },
});
g.canvas.viewport = { ...op.viewport };
g.canvas.updatedAt = now();
break;
}
case "auto_fit": {
inverseOps.push({
type: "set_viewport",
viewport: { ...g.canvas.viewport },
});
autoFitPadding = op.padding ?? 50;
break;
}
default: {
const bad = op as { type?: string };
throw new GraphError(
"invalid_op",
`unknown op ${String(bad?.type)}. Each op needs field "type" (not "op"). Valid: create_node, batch_create, batch_link, update_text, update_node_meta, move_node, reorder, set_primary_parent, link, unlink, delete_node, restore_node, set_pinned, set_collapsed, set_style_preset, set_edge_label, update_edge_meta, set_prefs, set_focus, set_viewport, auto_fit`,
);
}
}
}
g.canvas.updatedAt = now();
return {
graph: g,
inverseOps: inverseOps.reverse(),
affectedNodeIds: [...affected],
autoFitPadding,
};
}
export function toSnapshot(g: MutableGraph) {
return {
canvas: g.canvas,
nodes: liveNodes(g),
edges: liveEdges(g),
};
}