Browse Source
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
6 changed files with 2 additions and 841 deletions
@ -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(); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
||||
@ -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); |
|
||||
} |
|
||||
Loading…
Reference in new issue