From be0d0888eae310650a335e210bd32a27540c4909 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Jul 2026 00:23:28 +0800 Subject: [PATCH] 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 --- .../CanvasWorkspace/canvas/canvasHistory.ts | 106 ++++++++++++++++++ frontend/tests/canvasHistory.test.mjs | 54 +++++++++ 2 files changed, 160 insertions(+) create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/canvasHistory.ts create mode 100644 frontend/tests/canvasHistory.test.mjs diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasHistory.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasHistory.ts new file mode 100644 index 0000000..04a2742 --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasHistory.ts @@ -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; + +const styleKeys: Array = ["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, 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; +} diff --git a/frontend/tests/canvasHistory.test.mjs b/frontend/tests/canvasHistory.test.mjs new file mode 100644 index 0000000..614b88f --- /dev/null +++ b/frontend/tests/canvasHistory.test.mjs @@ -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('
    '), source.indexOf("
", source.indexOf('
    '))); + + assert.match(historyMarkup, /
    onPreviewHistory\(entry\.id\)\}/); + assert.match(historyMarkup, / onRestoreHistory\(entry\.id\)\}/); +});