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