diff --git a/package-lock.json b/package-lock.json index d51dc97a..59a9e162 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index e3765c2e..2b9898b1 100644 --- a/package.json +++ b/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": { diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index e268ce88..2ae6d4fd 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/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} diff --git a/src/store/__tests__/undoUtils.test.ts b/src/store/__tests__/undoUtils.test.ts deleted file mode 100644 index d3bc0758..00000000 --- a/src/store/__tests__/undoUtils.test.ts +++ /dev/null @@ -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, - }); - - 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(); - }); - }); -}); diff --git a/src/store/undoUtils.ts b/src/store/undoUtils.ts deleted file mode 100644 index 95fcfc88..00000000 --- a/src/store/undoUtils.ts +++ /dev/null @@ -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 = { - 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; - const strippedData: Record = {}; - - 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; -}; - -/** - * 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 { - 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); -} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 705479c9..6a4f69b1 100644 --- a/src/store/workflowStore.ts +++ b/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; - let nodeIdCounter = 0; let groupIdCounter = 0; let autoSaveIntervalId: ReturnType | null = null; @@ -377,8 +370,7 @@ async function waitForPendingImageSyncs(timeout: number = 60000): Promise export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage"; export { GROUP_COLORS } from "./utils/nodeDefaults"; -// Store implementation with temporal middleware for undo/redo -const workflowStoreImpl: StateCreator = (set, get) => ({ +const workflowStoreImpl: StateCreator = (set, get) => ({ nodes: [], edges: [], edgeStyle: "curved" as EdgeStyle, @@ -2121,22 +2113,9 @@ const workflowStoreImpl: StateCreator { - set((state) => ({ nodes: state.nodes })); - }, }); -export const useWorkflowStore = create()( - temporal( - workflowStoreImpl, - { - partialize: partializeForUndo as any, - limit: 20, - equality: undoStateEquality as any, - } - ) -); +export const useWorkflowStore = create()(workflowStoreImpl); /** * Stable hook for provider API keys.