From c0ec984192fbb650e05a47c5e8ac348c15b1cfaf Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 23 Feb 2026 12:29:59 +1300 Subject: [PATCH] feat(quick-010): install zundo and create binary data stripping utility - Install zundo temporal middleware for Zustand - Create src/store/undoUtils.ts with: - BINARY_DATA_KEYS: Set of 20+ binary field names to exclude - stripBinaryData(): Deep-clone nodes without binary data - partializeForUndo(): Extract trackable state slice - undoStateEquality(): Fast referential equality check - UndoState type for type safety --- package-lock.json | 21 ++++++- package.json | 3 +- src/store/undoUtils.ts | 121 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/store/undoUtils.ts diff --git a/package-lock.json b/package-lock.json index 8818b7bd..d51dc97a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.1.17", - "@types/three": "^0.182.0", "@xyflow/react": "^12.9.3", "ai": "^6.0.49", "autoprefixer": "^10.4.22", @@ -30,6 +29,7 @@ "react-markdown": "^10.1.0", "tailwindcss": "^4.1.17", "three": "^0.182.0", + "zundo": "^2.3.0", "zustand": "^5.0.9" }, "devDependencies": { @@ -39,6 +39,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.0.16", "jsdom": "^27.4.0", @@ -7810,6 +7811,24 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zundo": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", + "integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/charkour" + }, + "peerDependencies": { + "zustand": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "zustand": { + "optional": false + } + } + }, "node_modules/zustand": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", diff --git a/package.json b/package.json index 9e21827e..e3765c2e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-markdown": "^10.1.0", "tailwindcss": "^4.1.17", "three": "^0.182.0", + "zundo": "^2.3.0", "zustand": "^5.0.9" }, "devDependencies": { @@ -40,9 +41,9 @@ "@testing-library/react": "^16.3.1", "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", - "@types/three": "^0.182.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.0.16", "jsdom": "^27.4.0", diff --git a/src/store/undoUtils.ts b/src/store/undoUtils.ts new file mode 100644 index 00000000..c576221c --- /dev/null +++ b/src/store/undoUtils.ts @@ -0,0 +1,121 @@ +import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; +import type { EdgeStyle } from "./workflowStore"; + +/** + * Binary data field names that must be excluded from undo snapshots. + * These fields contain base64 data URLs that are large and unnecessary for undo/redo. + */ +export const BINARY_DATA_KEYS = new Set([ + // Image fields + "image", + "outputImage", + "sourceImage", + "inputImages", + "images", + "imageA", + "imageB", + "capturedImage", + + // Video fields + "outputVideo", + "video", + + // Audio fields + "audioFile", + "outputAudio", + "audio", + + // 3D fields + "glbUrl", + "output3dUrl", + + // History arrays (can be large) + "imageHistory", + "videoHistory", + "audioHistory", + "globalImageHistory", + + // Thumbnail fields + "thumbnail", + "thumbnails", +]); + +/** + * Strips binary data from node data objects to reduce undo snapshot size. + * Creates new node objects without mutating the originals. + * + * @param nodes - Array of workflow nodes + * @returns New array of nodes with binary data fields removed from node.data + */ +export function stripBinaryData(nodes: WorkflowNode[]): WorkflowNode[] { + return nodes.map(node => { + const strippedData: Record = {}; + + // Copy all non-binary fields + for (const key of Object.keys(node.data)) { + if (!BINARY_DATA_KEYS.has(key)) { + strippedData[key] = node.data[key]; + } + // Binary fields are simply omitted (will be undefined in the result) + } + + return { + ...node, + data: strippedData as typeof node.data, + }; + }); +} + +/** + * State shape tracked by undo/redo. + * Only includes fields that affect the workflow graph structure. + */ +export type UndoState = { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + edgeStyle: EdgeStyle; + groups: Record; +}; + +/** + * Partializes the store state for undo tracking. + * Returns only the fields we want to track in undo history. + * + * @param state - Full workflow store state + * @returns Partialized state with binary data stripped from nodes + */ +export function partializeForUndo(state: any): UndoState { + return { + nodes: stripBinaryData(state.nodes), + edges: state.edges, + edgeStyle: state.edgeStyle, + groups: state.groups, + }; +} + +/** + * Fast equality check for undo states using referential comparison. + * Zustand creates new object/array references on every mutation, + * so we can use === checks instead of deep equality. + * + * @param past - Previous undo state + * @param current - Current undo state + * @returns true if states are equal (skip snapshot), false otherwise + */ +export function undoStateEquality(past: UndoState, current: UndoState): boolean { + // Fast checks for primitive/reference changes + if (past.edges !== current.edges) return false; + if (past.edgeStyle !== current.edgeStyle) return false; + if (past.groups !== current.groups) return false; + + // Check nodes array + if (past.nodes.length !== current.nodes.length) return false; + + // Check each node reference (Zustand creates new refs on change) + for (let i = 0; i < past.nodes.length; i++) { + if (past.nodes[i] !== current.nodes[i]) return false; + } + + // All checks passed - states are equal + return true; +}