feat(canvas): add canvas history snapshot module
Add canvasHistory helpers that diff two node snapshots into a labeled operation (boolean, add, delete, path edit, move, resize, style, rename, reorder), keep the five most recent states, and truncate newer entries when an older state is restored. Workspace wiring lands in a follow-up commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import type { CanvasNode } from "@/domain/design";
|
||||
|
||||
export type CanvasHistoryOperation = {
|
||||
kind: "boolean" | "add" | "delete" | "path" | "move" | "resize" | "style" | "rename" | "reorder" | "edit";
|
||||
subject?: string;
|
||||
};
|
||||
|
||||
export type CanvasHistoryEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: number;
|
||||
nodes: CanvasNode[];
|
||||
};
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
const styleKeys: Array<keyof CanvasNode> = ["fillColor", "strokeColor", "strokeWidth", "strokeStyle", "opacity", "color", "fontFamily", "fontSize", "fontWeight", "textAlign", "lineHeight", "letterSpacing", "textDecoration", "textTransform"];
|
||||
|
||||
export function canvasHistoryOperation(previous: CanvasNode[], next: CanvasNode[]): CanvasHistoryOperation | null {
|
||||
if (sameCanvasNodes(previous, next)) return null;
|
||||
const previousById = new Map(previous.map((node) => [node.id, node]));
|
||||
const nextById = new Map(next.map((node) => [node.id, node]));
|
||||
const added = next.filter((node) => !previousById.has(node.id));
|
||||
const removed = previous.filter((node) => !nextById.has(node.id));
|
||||
|
||||
if (added.length === 1 && removed.length >= 2 && added[0].id.startsWith("boolean-") && isPathNode(added[0])) {
|
||||
return { kind: "boolean", subject: added[0].title.toLowerCase() };
|
||||
}
|
||||
if (added.length > 0) return { kind: "add", subject: historySubject(added) };
|
||||
if (removed.length > 0) return { kind: "delete", subject: historySubject(removed) };
|
||||
if (previous.map((node) => node.id).join("\n") !== next.map((node) => node.id).join("\n")) return { kind: "reorder" };
|
||||
|
||||
const changed = next.filter((node) => {
|
||||
const before = previousById.get(node.id);
|
||||
return before && JSON.stringify(before) !== JSON.stringify(node);
|
||||
});
|
||||
if (changed.length === 0) return { kind: "edit" };
|
||||
const subject = historySubject(changed);
|
||||
if (changed.some((node) => previousById.get(node.id)?.content !== node.content && isPathNode(node))) return { kind: "path", subject };
|
||||
if (changed.every((node) => sizeChanged(previousById.get(node.id)!, node))) return { kind: "resize", subject };
|
||||
if (changed.every((node) => positionChanged(previousById.get(node.id)!, node))) return { kind: "move", subject };
|
||||
if (changed.every((node) => styleKeys.some((key) => previousById.get(node.id)?.[key] !== node[key]))) return { kind: "style", subject };
|
||||
if (changed.every((node) => previousById.get(node.id)?.title !== node.title)) return { kind: "rename", subject };
|
||||
return { kind: "edit", subject };
|
||||
}
|
||||
|
||||
export function canvasHistoryOperationLabel(operation: CanvasHistoryOperation, t: Translate) {
|
||||
if (operation.kind === "boolean") {
|
||||
const operationKey = operation.subject === "subtract" ? "booleanSubtract" : operation.subject === "intersect" ? "booleanIntersect" : operation.subject === "exclude" ? "booleanExclude" : operation.subject === "flatten" ? "booleanFlatten" : "booleanUnion";
|
||||
return t("historyBooleanOperation", { operation: t(operationKey) });
|
||||
}
|
||||
const keyByKind: Record<Exclude<CanvasHistoryOperation["kind"], "boolean">, string> = {
|
||||
add: "historyAdd",
|
||||
delete: "historyDelete",
|
||||
path: "historyEditPath",
|
||||
move: "historyMove",
|
||||
resize: "historyResize",
|
||||
style: "historyStyle",
|
||||
rename: "historyRename",
|
||||
reorder: "historyReorder",
|
||||
edit: "historyEdit"
|
||||
};
|
||||
const subject = operation.subject && /^\d+$/.test(operation.subject) ? t("layerItemsCount", { count: Number(operation.subject) }) : operation.subject;
|
||||
return t(keyByKind[operation.kind], subject ? { name: subject } : undefined);
|
||||
}
|
||||
|
||||
export function appendCanvasHistory(entries: CanvasHistoryEntry[], entry: CanvasHistoryEntry, activeId: string | null, limit = 5) {
|
||||
const activeIndex = activeId ? entries.findIndex((item) => item.id === activeId) : -1;
|
||||
const branch = activeIndex >= 0 ? entries.slice(activeIndex) : entries;
|
||||
return [entry, ...branch].slice(0, limit);
|
||||
}
|
||||
|
||||
export function truncateCanvasHistory(entries: CanvasHistoryEntry[], activeId: string) {
|
||||
const activeIndex = entries.findIndex((item) => item.id === activeId);
|
||||
return activeIndex >= 0 ? entries.slice(activeIndex) : entries;
|
||||
}
|
||||
|
||||
export function cloneCanvasHistoryNodes(nodes: CanvasNode[]) {
|
||||
return JSON.parse(JSON.stringify(nodes)) as CanvasNode[];
|
||||
}
|
||||
|
||||
export function sameCanvasNodes(first: CanvasNode[], second: CanvasNode[]) {
|
||||
return JSON.stringify(first) === JSON.stringify(second);
|
||||
}
|
||||
|
||||
function historySubject(nodes: CanvasNode[]) {
|
||||
if (nodes.length !== 1) return String(nodes.length);
|
||||
return nodes[0].title.trim() || nodes[0].type;
|
||||
}
|
||||
|
||||
function isPathNode(node: CanvasNode) {
|
||||
if (node.type !== "frame") return false;
|
||||
try {
|
||||
return JSON.parse(node.content)?.kind === "path";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sizeChanged(previous: CanvasNode, next: CanvasNode) {
|
||||
return previous.width !== next.width || previous.height !== next.height;
|
||||
}
|
||||
|
||||
function positionChanged(previous: CanvasNode, next: CanvasNode) {
|
||||
return previous.x !== next.x || previous.y !== next.y;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { appendCanvasHistory, canvasHistoryOperation, truncateCanvasHistory } from "../src/ui/pages/CanvasWorkspace/canvas/canvasHistory.ts";
|
||||
|
||||
const rectangle = {
|
||||
id: "shape-1",
|
||||
type: "frame",
|
||||
title: "Rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
content: JSON.stringify({ kind: "rectangle" }),
|
||||
tone: "shape",
|
||||
status: "draft",
|
||||
layerRole: "shape"
|
||||
};
|
||||
|
||||
test("describes major canvas operations", () => {
|
||||
assert.deepEqual(canvasHistoryOperation([], [rectangle]), { kind: "add", subject: "Rectangle" });
|
||||
assert.deepEqual(canvasHistoryOperation([rectangle], [{ ...rectangle, x: 40 }]), { kind: "move", subject: "Rectangle" });
|
||||
assert.deepEqual(canvasHistoryOperation([rectangle], [{ ...rectangle, width: 160 }]), { kind: "resize", subject: "Rectangle" });
|
||||
assert.deepEqual(canvasHistoryOperation([rectangle], [{ ...rectangle, fillColor: "#ff0000" }]), { kind: "style", subject: "Rectangle" });
|
||||
assert.deepEqual(canvasHistoryOperation([rectangle], [{ ...rectangle, content: JSON.stringify({ kind: "path", pathData: [1, 0, 0, 2, 1, 1] }) }]), { kind: "path", subject: "Rectangle" });
|
||||
});
|
||||
|
||||
test("recognizes a boolean result as one major operation", () => {
|
||||
const second = { ...rectangle, id: "shape-2", x: 50 };
|
||||
const result = { ...rectangle, id: "boolean-result", title: "UNION", width: 150, content: JSON.stringify({ kind: "path", pathData: [1, 0, 0, 2, 1, 0, 2, 1, 1, 11] }) };
|
||||
|
||||
assert.deepEqual(canvasHistoryOperation([rectangle, second], [result]), { kind: "boolean", subject: "union" });
|
||||
});
|
||||
|
||||
test("keeps five recent states and removes newer states when restoring", () => {
|
||||
const entries = Array.from({ length: 5 }, (_, index) => ({ id: `history-${5 - index}`, label: String(5 - index), createdAt: 5 - index, nodes: [] }));
|
||||
const appended = appendCanvasHistory(entries, { id: "history-6", label: "6", createdAt: 6, nodes: [] }, null);
|
||||
const restored = truncateCanvasHistory(entries, "history-3");
|
||||
const branched = appendCanvasHistory(restored, { id: "history-branch", label: "branch", createdAt: 7, nodes: [] }, "history-3");
|
||||
|
||||
assert.deepEqual(appended.map((entry) => entry.id), ["history-6", "history-5", "history-4", "history-3", "history-2"]);
|
||||
assert.deepEqual(restored.map((entry) => entry.id), ["history-3", "history-2", "history-1"]);
|
||||
assert.deepEqual(branched.map((entry) => entry.id), ["history-branch", "history-3", "history-2", "history-1"]);
|
||||
});
|
||||
|
||||
test("previews history from the row and restores only from the rollback button", async () => {
|
||||
const source = await readFile(new URL("../src/ui/components/CanvasPanels/index.tsx", import.meta.url), "utf8");
|
||||
const historyMarkup = source.slice(source.indexOf('<ol className="layer-history-list">'), source.indexOf("</ol>", source.indexOf('<ol className="layer-history-list">')));
|
||||
|
||||
assert.match(historyMarkup, /<div className=\{`layer-history-entry/);
|
||||
assert.match(historyMarkup, /<button[\s\S]*?className="layer-history-preview"[\s\S]*?onClick=\{\(\) => onPreviewHistory\(entry\.id\)\}/);
|
||||
assert.match(historyMarkup, /<button[\s\S]*?className="layer-history-restore"[\s\S]*?onClick=\{\(\) => onRestoreHistory\(entry\.id\)\}/);
|
||||
});
|
||||
Reference in New Issue
Block a user