Browse Source

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
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
c0ec984192
  1. 21
      package-lock.json
  2. 3
      package.json
  3. 121
      src/store/undoUtils.ts

21
package-lock.json

@ -14,7 +14,6 @@
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@types/three": "^0.182.0",
"@xyflow/react": "^12.9.3", "@xyflow/react": "^12.9.3",
"ai": "^6.0.49", "ai": "^6.0.49",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
@ -30,6 +29,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"three": "^0.182.0", "three": "^0.182.0",
"zundo": "^2.3.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@ -39,6 +39,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16", "@vitest/coverage-v8": "^4.0.16",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
@ -7810,6 +7811,24 @@
"url": "https://github.com/sponsors/colinhacks" "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": { "node_modules/zustand": {
"version": "5.0.9", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",

3
package.json

@ -33,6 +33,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"three": "^0.182.0", "three": "^0.182.0",
"zundo": "^2.3.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@ -40,9 +41,9 @@
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/three": "^0.182.0",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16", "@vitest/coverage-v8": "^4.0.16",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",

121
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<string, any> = {};
// 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<string, NodeGroup>;
};
/**
* 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;
}
Loading…
Cancel
Save