Browse Source

fix: remove broken zundo undo/redo system

Zundo-based undo/redo was causing image flickering, failing to restore
deleted nodes, and creating memory issues from large state snapshots.
Removes the entire workflow undo/redo system while keeping the
independent annotation canvas undo/redo intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
8baa4e38d0
  1. 19
      package-lock.json
  2. 1
      package.json
  3. 32
      src/components/WorkflowCanvas.tsx
  4. 614
      src/store/__tests__/undoUtils.test.ts
  5. 152
      src/store/undoUtils.ts
  6. 25
      src/store/workflowStore.ts

19
package-lock.json

@ -29,7 +29,6 @@
"react-markdown": "^10.1.0",
"tailwindcss": "^4.1.17",
"three": "^0.182.0",
"zundo": "^2.3.0",
"zustand": "^5.0.9"
},
"devDependencies": {
@ -7811,24 +7810,6 @@
"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",

1
package.json

@ -33,7 +33,6 @@
"react-markdown": "^10.1.0",
"tailwindcss": "^4.1.17",
"three": "^0.182.0",
"zundo": "^2.3.0",
"zustand": "^5.0.9"
},
"devDependencies": {

32
src/components/WorkflowCanvas.tsx

@ -18,7 +18,6 @@ import {
import "@xyflow/react/dist/style.css";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import { undoWithMedia, redoWithMedia } from "@/store/undoUtils";
import { useToast } from "@/components/Toast";
import dynamic from "next/dynamic";
import {
@ -282,10 +281,6 @@ export function WorkflowCanvas() {
// Check if a node was dropped into a group and add it to that group
const handleNodeDragStart = useCallback(() => {
useWorkflowStore.temporal.getState().pause();
}, []);
const handleNodeDragStop = useCallback(
(_event: React.MouseEvent, node: Node) => {
// Skip if it's a group node
@ -318,11 +313,6 @@ export function WorkflowCanvas() {
if (targetGroupId !== currentGroupId) {
setNodeGroupId(node.id, targetGroupId);
}
// Resume undo tracking after drag and capture final position
const temporal = useWorkflowStore.temporal.getState();
temporal.resume();
useWorkflowStore.getState()._nudgeForSnapshot();
},
[groups, nodes, setNodeGroupId]
);
@ -1091,27 +1081,6 @@ export function WorkflowCanvas() {
return;
}
// Handle undo (Ctrl/Cmd + Z, but NOT Ctrl/Cmd + Shift + Z)
if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && !event.shiftKey) {
event.preventDefault();
undoWithMedia(useWorkflowStore);
return;
}
// Handle redo (Ctrl/Cmd + Shift + Z)
if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && event.shiftKey) {
event.preventDefault();
redoWithMedia(useWorkflowStore);
return;
}
// Handle redo alt (Ctrl + Y — Windows convention)
if ((event.ctrlKey || event.metaKey) && event.key === "y") {
event.preventDefault();
redoWithMedia(useWorkflowStore);
return;
}
// Handle copy (Ctrl/Cmd + C)
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
event.preventDefault();
@ -1674,7 +1643,6 @@ export function WorkflowCanvas() {
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onConnectEnd={handleConnectEnd}
onNodeDragStart={handleNodeDragStart}
onNodeDragStop={handleNodeDragStop}
onSelectionChange={handleSelectionChange}
nodeTypes={nodeTypes}

614
src/store/__tests__/undoUtils.test.ts

@ -1,614 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { stripBinaryData, undoStateEquality, partializeForUndo, BINARY_TO_REF, undoWithMedia, redoWithMedia } from "../undoUtils";
import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types";
import type { EdgeStyle } from "../workflowStore";
// Mock imageStorage to avoid real file I/O
vi.mock("@/utils/imageStorage", () => ({
hydrateWorkflowImages: vi.fn(async (workflow: any) => workflow),
}));
describe("undoUtils", () => {
describe("BINARY_TO_REF", () => {
it("maps all ref-backed binary fields", () => {
expect(BINARY_TO_REF).toEqual({
image: "imageRef",
outputImage: "outputImageRef",
sourceImage: "sourceImageRef",
inputImages: "inputImageRefs",
outputVideo: "outputVideoRef",
outputAudio: "outputAudioRef",
});
});
it("does not include fields without refs (imageA, imageB, etc.)", () => {
const noRefFields = ["imageA", "imageB", "capturedImage", "video", "images", "audioFile", "glbUrl", "output3dUrl"];
for (const field of noRefFields) {
expect(BINARY_TO_REF).not.toHaveProperty(field);
}
});
});
describe("stripBinaryData", () => {
it("strips binary fields that have corresponding refs", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,ABC123",
imageRef: "img_001",
prompt: "test prompt",
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).not.toHaveProperty("image");
expect(stripped[0].data).toHaveProperty("imageRef", "img_001");
expect(stripped[0].data).toHaveProperty("prompt", "test prompt");
});
it("keeps binary fields that have NO refs", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,ABC123",
// No imageRef → keep image in snapshot
prompt: "test prompt",
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).toHaveProperty("image", "data:image/png;base64,ABC123");
expect(stripped[0].data).toHaveProperty("prompt", "test prompt");
});
it("strips outputImage and sourceImage when refs exist", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "annotation",
position: { x: 0, y: 0 },
data: {
outputImage: "data:image/png;base64,OUT123",
outputImageRef: "img_out_001",
sourceImage: "data:image/png;base64,SRC123",
sourceImageRef: "img_src_001",
prompt: "test",
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).not.toHaveProperty("outputImage");
expect(stripped[0].data).not.toHaveProperty("sourceImage");
expect(stripped[0].data).toHaveProperty("outputImageRef", "img_out_001");
expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_src_001");
});
it("strips outputVideo when ref exists", () => {
const nodes = [
{
id: "node-1",
type: "generateVideo",
position: { x: 0, y: 0 },
data: {
outputVideo: "data:video/mp4;base64,VIDEO123",
outputVideoRef: "vid_001",
model: "test-model",
},
},
] as unknown as WorkflowNode[];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).not.toHaveProperty("outputVideo");
expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001");
expect(stripped[0].data).toHaveProperty("model", "test-model");
});
it("strips outputAudio when ref exists", () => {
const nodes = [
{
id: "node-1",
type: "generateAudio",
position: { x: 0, y: 0 },
data: {
outputAudio: "data:audio/mp3;base64,AUDIO456",
outputAudioRef: "aud_001",
fileName: "test.mp3",
},
},
] as unknown as WorkflowNode[];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).not.toHaveProperty("outputAudio");
expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001");
expect(stripped[0].data).toHaveProperty("fileName", "test.mp3");
});
it("keeps fields without refs: imageA, imageB, capturedImage, video, glbUrl, etc.", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "imageCompare",
position: { x: 0, y: 0 },
data: {
imageA: "data:image/png;base64,IMGA",
imageB: "data:image/png;base64,IMGB",
capturedImage: "data:image/png;base64,CAP",
video: "data:video/mp4;base64,VID",
glbUrl: "data:model/gltf-binary;base64,GLB",
output3dUrl: "data:model/gltf-binary;base64,GLB2",
audioFile: "data:audio/mp3;base64,AUD",
images: ["data:image/png;base64,IMG1"],
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).toHaveProperty("imageA", "data:image/png;base64,IMGA");
expect(stripped[0].data).toHaveProperty("imageB", "data:image/png;base64,IMGB");
expect(stripped[0].data).toHaveProperty("capturedImage", "data:image/png;base64,CAP");
expect(stripped[0].data).toHaveProperty("video", "data:video/mp4;base64,VID");
expect(stripped[0].data).toHaveProperty("glbUrl", "data:model/gltf-binary;base64,GLB");
expect(stripped[0].data).toHaveProperty("output3dUrl", "data:model/gltf-binary;base64,GLB2");
expect(stripped[0].data).toHaveProperty("audioFile", "data:audio/mp3;base64,AUD");
expect(stripped[0].data).toHaveProperty("images");
});
it("preserves carousel history arrays (metadata only)", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "nanoBanana",
position: { x: 0, y: 0 },
data: {
imageHistory: [
{ timestamp: "2024-01-01", imageRef: "img_001" },
{ timestamp: "2024-01-02", imageRef: "img_002" },
],
videoHistory: [
{ timestamp: "2024-01-01", videoRef: "vid_001" },
],
audioHistory: [
{ timestamp: "2024-01-01", audioRef: "aud_001" },
],
prompt: "test prompt",
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).toHaveProperty("imageHistory");
expect(stripped[0].data).toHaveProperty("videoHistory");
expect(stripped[0].data).toHaveProperty("audioHistory");
expect(stripped[0].data).toHaveProperty("prompt", "test prompt");
});
it("preserves ref fields", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,ABC123",
imageRef: "img_001",
outputImageRef: "img_002",
sourceImageRef: "img_003",
inputImageRefs: ["img_004", "img_005"],
outputVideoRef: "vid_001",
outputAudioRef: "aud_001",
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).not.toHaveProperty("image");
expect(stripped[0].data).toHaveProperty("imageRef", "img_001");
expect(stripped[0].data).toHaveProperty("outputImageRef", "img_002");
expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_003");
expect(stripped[0].data).toHaveProperty("inputImageRefs");
expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001");
expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001");
});
it("preserves non-binary fields", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "nanoBanana",
position: { x: 100, y: 200 },
data: {
image: "data:image/png;base64,ABC123",
imageRef: "img_001",
prompt: "test prompt",
model: "gemini-2.5-flash-image",
aspectRatio: "1:1",
selectedModel: { provider: "gemini", modelId: "test" },
seed: 12345,
steps: 20,
guidanceScale: 7.5,
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).not.toHaveProperty("image");
expect(stripped[0].data).toHaveProperty("prompt", "test prompt");
expect(stripped[0].data).toHaveProperty("model", "gemini-2.5-flash-image");
expect(stripped[0].data).toHaveProperty("aspectRatio", "1:1");
expect(stripped[0].data).toHaveProperty("selectedModel");
expect(stripped[0].data).toHaveProperty("seed", 12345);
expect(stripped[0].data).toHaveProperty("steps", 20);
expect(stripped[0].data).toHaveProperty("guidanceScale", 7.5);
});
it("does not mutate original nodes", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,ABC123",
imageRef: "img_001",
prompt: "test",
},
},
];
const originalImage = nodes[0].data.image;
stripBinaryData(nodes);
expect(nodes[0].data.image).toBe(originalImage);
expect(nodes[0].data).toHaveProperty("image", "data:image/png;base64,ABC123");
});
it("handles empty nodes array", () => {
const nodes: WorkflowNode[] = [];
const stripped = stripBinaryData(nodes);
expect(stripped).toEqual([]);
});
it("handles nodes with no binary data", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "prompt",
position: { x: 0, y: 0 },
data: {
prompt: "test prompt",
variableName: "myPrompt",
},
},
];
const stripped = stripBinaryData(nodes);
expect(stripped[0].data).toEqual({
prompt: "test prompt",
variableName: "myPrompt",
});
});
it("handles mixed nodes: some with refs, some without", () => {
const nodes: WorkflowNode[] = [
{
id: "node-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,SAVED",
imageRef: "img_001", // Has ref → strip
},
},
{
id: "node-2",
type: "imageInput",
position: { x: 100, y: 0 },
data: {
image: "data:image/png;base64,UNSAVED",
// No imageRef → keep
},
},
];
const stripped = stripBinaryData(nodes);
// Node 1: stripped (has ref)
expect(stripped[0].data).not.toHaveProperty("image");
expect(stripped[0].data).toHaveProperty("imageRef", "img_001");
// Node 2: kept (no ref)
expect(stripped[1].data).toHaveProperty("image", "data:image/png;base64,UNSAVED");
});
});
describe("undoStateEquality", () => {
const createMockState = () => ({
nodes: [
{
id: "node-1",
type: "prompt" as const,
position: { x: 0, y: 0 },
data: { prompt: "test" },
},
],
edges: [
{
id: "edge-1",
source: "node-1",
target: "node-2",
data: {},
},
] as WorkflowEdge[],
edgeStyle: "curved" as EdgeStyle,
groups: {} as Record<string, NodeGroup>,
});
it("returns true when same object refs (no change)", () => {
const state = createMockState();
const result = undoStateEquality(state, state);
expect(result).toBe(true);
});
it("returns false when edges array differs", () => {
const state1 = createMockState();
const state2 = createMockState();
state2.edges = [...state2.edges]; // New array ref
const result = undoStateEquality(state1, state2);
expect(result).toBe(false);
});
it("returns false when edgeStyle differs", () => {
const state1 = createMockState();
const state2 = createMockState();
state2.edgeStyle = "angular";
const result = undoStateEquality(state1, state2);
expect(result).toBe(false);
});
it("returns false when groups object differs", () => {
const state1 = createMockState();
const state2 = createMockState();
state2.groups = { ...state2.groups }; // New object ref
const result = undoStateEquality(state1, state2);
expect(result).toBe(false);
});
it("returns false when node count differs", () => {
const state1 = createMockState();
const state2 = createMockState();
state2.nodes = [
...state2.nodes,
{
id: "node-2",
type: "prompt" as const,
position: { x: 100, y: 100 },
data: { prompt: "test2" },
},
];
const result = undoStateEquality(state1, state2);
expect(result).toBe(false);
});
it("returns false when any node ref differs (position change)", () => {
const state1 = createMockState();
const state2 = createMockState();
state2.nodes = [
{
...state2.nodes[0],
position: { x: 100, y: 100 },
},
];
const result = undoStateEquality(state1, state2);
expect(result).toBe(false);
});
it("returns true when all node refs are identical even with different wrapper array", () => {
const state1 = createMockState();
const state2 = {
...state1,
nodes: [...state1.nodes], // New array ref but same node refs
};
const result = undoStateEquality(state1, state2);
expect(result).toBe(true);
});
});
describe("partializeForUndo", () => {
it("returns object with only nodes, edges, edgeStyle, groups", () => {
const mockState = {
nodes: [],
edges: [],
edgeStyle: "curved" as EdgeStyle,
groups: {},
isRunning: false,
clipboard: null,
openModalCount: 0,
workflowId: "test-id",
hasUnsavedChanges: true,
};
const result = partializeForUndo(mockState);
expect(Object.keys(result)).toEqual(["nodes", "edges", "edgeStyle", "groups"]);
expect(result).not.toHaveProperty("isRunning");
expect(result).not.toHaveProperty("clipboard");
expect(result).not.toHaveProperty("openModalCount");
expect(result).not.toHaveProperty("workflowId");
expect(result).not.toHaveProperty("hasUnsavedChanges");
});
it("returned nodes have recoverable binary data stripped", () => {
const mockState = {
nodes: [
{
id: "node-1",
type: "imageInput" as const,
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,ABC123",
imageRef: "img_001",
prompt: "test",
},
},
],
edges: [],
edgeStyle: "curved" as EdgeStyle,
groups: {},
isRunning: false,
};
const result = partializeForUndo(mockState);
expect(result.nodes[0].data).not.toHaveProperty("image");
expect(result.nodes[0].data).toHaveProperty("imageRef", "img_001");
expect(result.nodes[0].data).toHaveProperty("prompt", "test");
});
it("returned nodes keep unrecoverable binary data", () => {
const mockState = {
nodes: [
{
id: "node-1",
type: "imageInput" as const,
position: { x: 0, y: 0 },
data: {
image: "data:image/png;base64,UNSAVED",
// No imageRef
prompt: "test",
},
},
],
edges: [],
edgeStyle: "curved" as EdgeStyle,
groups: {},
isRunning: false,
};
const result = partializeForUndo(mockState);
expect(result.nodes[0].data).toHaveProperty("image", "data:image/png;base64,UNSAVED");
expect(result.nodes[0].data).toHaveProperty("prompt", "test");
});
});
describe("undoWithMedia", () => {
it("calls undo when pastStates is non-empty", () => {
const undo = vi.fn();
const pause = vi.fn();
const resume = vi.fn();
const mockStore = {
getState: vi.fn(() => ({ nodes: [], edges: [], saveDirectoryPath: null })),
setState: vi.fn(),
temporal: {
getState: vi.fn(() => ({
pastStates: [{}],
undo,
pause,
resume,
})),
},
} as any;
undoWithMedia(mockStore);
expect(undo).toHaveBeenCalled();
expect(mockStore.setState).toHaveBeenCalledWith({ hasUnsavedChanges: true });
});
it("does nothing when pastStates is empty", () => {
const undo = vi.fn();
const mockStore = {
getState: vi.fn(),
setState: vi.fn(),
temporal: {
getState: vi.fn(() => ({
pastStates: [],
undo,
})),
},
} as any;
undoWithMedia(mockStore);
expect(undo).not.toHaveBeenCalled();
expect(mockStore.setState).not.toHaveBeenCalled();
});
});
describe("redoWithMedia", () => {
it("calls redo when futureStates is non-empty", () => {
const redo = vi.fn();
const pause = vi.fn();
const resume = vi.fn();
const mockStore = {
getState: vi.fn(() => ({ nodes: [], edges: [], saveDirectoryPath: null })),
setState: vi.fn(),
temporal: {
getState: vi.fn(() => ({
futureStates: [{}],
redo,
pause,
resume,
})),
},
} as any;
redoWithMedia(mockStore);
expect(redo).toHaveBeenCalled();
expect(mockStore.setState).toHaveBeenCalledWith({ hasUnsavedChanges: true });
});
it("does nothing when futureStates is empty", () => {
const redo = vi.fn();
const mockStore = {
getState: vi.fn(),
setState: vi.fn(),
temporal: {
getState: vi.fn(() => ({
futureStates: [],
redo,
})),
},
} as any;
redoWithMedia(mockStore);
expect(redo).not.toHaveBeenCalled();
expect(mockStore.setState).not.toHaveBeenCalled();
});
});
});

152
src/store/undoUtils.ts

@ -1,152 +0,0 @@
import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types";
import type { EdgeStyle } from "./workflowStore";
import { hydrateWorkflowImages } from "@/utils/imageStorage";
/**
* Mapping of binary data fields to their corresponding ref fields.
* Only fields with a ref can be recovered from disk after undo/redo,
* so only these are stripped from snapshots.
*
* Fields WITHOUT refs (imageA, imageB, capturedImage, video, images,
* audioFile, glbUrl, output3dUrl, etc.) are kept in snapshots since
* they're unrecoverable otherwise.
*/
export const BINARY_TO_REF: Record<string, string> = {
image: "imageRef",
outputImage: "outputImageRef",
sourceImage: "sourceImageRef",
inputImages: "inputImageRefs",
outputVideo: "outputVideoRef",
outputAudio: "outputAudioRef",
};
/**
* Strips binary data from node data objects to reduce undo snapshot size.
* Only strips fields that have a corresponding ref (recoverable from disk).
* Fields without refs are kept in the snapshot since they can't be recovered.
*
* @param nodes - Array of workflow nodes
* @returns New array of nodes with recoverable binary data stripped
*/
export function stripBinaryData(nodes: WorkflowNode[]): WorkflowNode[] {
return nodes.map(node => {
const data = node.data as Record<string, any>;
const strippedData: Record<string, any> = {};
for (const key of Object.keys(data)) {
const refField = BINARY_TO_REF[key];
if (refField && data[refField]) {
// Has a ref → strip binary (recoverable from disk)
continue;
}
strippedData[key] = data[key];
}
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;
}
/**
* Hydrate missing binary data from refs after undo/redo.
* Uses the existing hydrateWorkflowImages() which checks `if (ref && !binary)`
* before loading, so it only loads what's actually missing.
*/
async function hydrateAfterUndoRedo(store: typeof import("./workflowStore").useWorkflowStore): Promise<void> {
const state = store.getState();
const workflowPath = state.saveDirectoryPath;
if (!workflowPath) return; // Unsaved workflow, no refs to hydrate
try {
const workflow = { version: 1 as const, name: "", nodes: state.nodes, edges: state.edges, edgeStyle: state.edgeStyle };
const hydrated = await hydrateWorkflowImages(workflow, workflowPath);
// Pause temporal so hydration doesn't create undo snapshots
const temporal = store.temporal.getState();
temporal.pause();
store.setState({ nodes: hydrated.nodes });
temporal.resume();
} catch (err) {
console.warn("Failed to hydrate after undo/redo:", err);
}
}
/**
* Undo with media hydration. Calls undo(), marks unsaved, then
* asynchronously hydrates any binary data that was stripped from the snapshot.
*/
export function undoWithMedia(store: typeof import("./workflowStore").useWorkflowStore): void {
const temporal = store.temporal.getState();
if (temporal.pastStates.length === 0) return;
temporal.undo();
store.setState({ hasUnsavedChanges: true });
hydrateAfterUndoRedo(store);
}
/**
* Redo with media hydration. Calls redo(), marks unsaved, then
* asynchronously hydrates any binary data that was stripped from the snapshot.
*/
export function redoWithMedia(store: typeof import("./workflowStore").useWorkflowStore): void {
const temporal = store.temporal.getState();
if (temporal.futureStates.length === 0) return;
temporal.redo();
store.setState({ hasUnsavedChanges: true });
hydrateAfterUndoRedo(store);
}

25
src/store/workflowStore.ts

@ -1,6 +1,5 @@
import { create, StateCreator } from "zustand";
import { useShallow } from "zustand/shallow";
import { temporal, TemporalState } from "zundo";
import {
Connection,
EdgeChange,
@ -44,7 +43,6 @@ import {
getCanvasNavigationSettings,
saveCanvasNavigationSettings,
} from "./utils/localStorage";
import { partializeForUndo, undoStateEquality } from "./undoUtils";
import {
createDefaultNodeData,
defaultNodeDimensions,
@ -336,13 +334,8 @@ interface WorkflowStore {
// Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void;
// Undo/Redo helper actions
_nudgeForSnapshot: () => void;
}
// Extend with TemporalState for undo/redo functionality
export type WorkflowStoreWithTemporal = WorkflowStore & TemporalState<WorkflowStore>;
let nodeIdCounter = 0;
let groupIdCounter = 0;
let autoSaveIntervalId: ReturnType<typeof setInterval> | null = null;
@ -377,8 +370,7 @@ async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void>
export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage";
export { GROUP_COLORS } from "./utils/nodeDefaults";
// Store implementation with temporal middleware for undo/redo
const workflowStoreImpl: StateCreator<WorkflowStore, [["temporal", unknown]], []> = (set, get) => ({
const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
nodes: [],
edges: [],
edgeStyle: "curved" as EdgeStyle,
@ -2121,22 +2113,9 @@ const workflowStoreImpl: StateCreator<WorkflowStore, [["temporal", unknown]], []
saveCanvasNavigationSettings(settings);
},
// Undo/Redo helper actions
_nudgeForSnapshot: () => {
set((state) => ({ nodes: state.nodes }));
},
});
export const useWorkflowStore = create<WorkflowStore>()(
temporal(
workflowStoreImpl,
{
partialize: partializeForUndo as any,
limit: 20,
equality: undoStateEquality as any,
}
)
);
export const useWorkflowStore = create<WorkflowStore>()(workflowStoreImpl);
/**
* Stable hook for provider API keys.

Loading…
Cancel
Save