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\)\}/); +});