diff --git a/frontend/src/stores/useCanvasStore.ts b/frontend/src/stores/useCanvasStore.ts index 0693eac..3021c84 100644 --- a/frontend/src/stores/useCanvasStore.ts +++ b/frontend/src/stores/useCanvasStore.ts @@ -18,6 +18,18 @@ const isWorkflowNode = (n: Node): n is Node => n const canvasChangeListeners = new Set<() => void>(); +interface CanvasSnapshot { + nodes: Node[]; + edges: Edge[]; + selectedNodeId: string | null; +} + +function snapshotCanvas(state: CanvasState): CanvasSnapshot { + // Pending cards are transient; undo must never restore an abandoned spinner. + const nodes = state.nodes.filter((node) => !isImageCardNode(node) || !node.data.isGenerating); + return structuredClone({ nodes, edges: state.edges, selectedNodeId: nodes.some((node) => node.id === state.selectedNodeId) ? state.selectedNodeId : null }); +} + interface CanvasState { nodes: Node[]; edges: Edge[]; @@ -26,8 +38,9 @@ interface CanvasState { selectedNodeId: string | null; isExecuting: boolean; currentRunId: string | null; - past: Node[][]; - future: Node[][]; + past: CanvasSnapshot[]; + future: CanvasSnapshot[]; + isDragging: boolean; setViewport: (viewport: ProjectViewport) => void; addGenerationHistory: (item: GenerationHistoryItem) => void; @@ -65,6 +78,7 @@ export const useCanvasStore = create((set, get) => ({ currentRunId: null, past: [], future: [], + isDragging: false, subscribeCanvasChange: (listener: () => void) => { canvasChangeListeners.add(listener); @@ -104,39 +118,42 @@ export const useCanvasStore = create((set, get) => ({ selectedNodeId: null, past: [], future: [], + isDragging: false, }); }, pushSnapshot: () => { - const currentNodes = structuredClone(get().nodes); + const currentCanvas = snapshotCanvas(get()); set((state) => ({ - past: [...state.past.slice(-24), currentNodes], + past: [...state.past.slice(-24), currentCanvas], future: [], })); }, undo: () => { - const { past, future, nodes } = get(); + const { past, future } = get(); if (past.length === 0) return; const previous = past[past.length - 1]; const newPast = past.slice(0, -1); set({ - nodes: previous, + ...structuredClone(previous), past: newPast, - future: [structuredClone(nodes), ...future], + future: [snapshotCanvas(get()), ...future], + isDragging: false, }); get().notifyCanvasChange(); }, redo: () => { - const { past, future, nodes } = get(); + const { past, future } = get(); if (future.length === 0) return; const next = future[0]; const newFuture = future.slice(1); set({ - nodes: next, - past: [...past, structuredClone(nodes)], + ...structuredClone(next), + past: [...past, snapshotCanvas(get())], future: newFuture, + isDragging: false, }); get().notifyCanvasChange(); }, @@ -292,8 +309,18 @@ export const useCanvasStore = create((set, get) => ({ }, onNodesChange: (changes) => { + const structural = changes.some((change) => change.type === 'add' || change.type === 'replace' || + (change.type === 'remove' && get().nodes.some((node) => node.id === change.id))); + const movement = changes.filter((change) => (change.type === 'position' && change.position) || + (change.type === 'dimensions' && change.resizing !== undefined)); + if (structural || (movement.length > 0 && !get().isDragging)) get().pushSnapshot(); + const removed = new Set(changes.filter((change) => change.type === 'remove').map((change) => change.id)); set({ nodes: applyNodeChanges(changes, get().nodes), + edges: get().edges.filter((edge) => !removed.has(edge.source) && !removed.has(edge.target)), + selectedNodeId: removed.has(get().selectedNodeId || '') ? null : get().selectedNodeId, + isDragging: movement.length ? movement.some((change) => (change.type === 'position' && change.dragging) || + (change.type === 'dimensions' && change.resizing)) : get().isDragging, }); if (changes.some((c) => c.type !== 'select')) { get().notifyCanvasChange(); @@ -301,6 +328,8 @@ export const useCanvasStore = create((set, get) => ({ }, onEdgesChange: (changes) => { + if (changes.some((change) => change.type === 'add' || change.type === 'replace' || + (change.type === 'remove' && get().edges.some((edge) => edge.id === change.id)))) get().pushSnapshot(); set({ edges: applyEdgeChanges(changes, get().edges), }); @@ -310,6 +339,7 @@ export const useCanvasStore = create((set, get) => ({ }, onConnect: (connection) => { + get().pushSnapshot(); set({ edges: addEdge( { @@ -371,6 +401,7 @@ export const useCanvasStore = create((set, get) => ({ }, updateNodeParam: (nodeId, paramName, value) => { + get().pushSnapshot(); set({ nodes: get().nodes.map((node) => { if (node.id !== nodeId || !isWorkflowNode(node)) return node; diff --git a/frontend/src/stores/useCreativeStore.ts b/frontend/src/stores/useCreativeStore.ts index 496499b..bb9bf40 100644 --- a/frontend/src/stores/useCreativeStore.ts +++ b/frontend/src/stores/useCreativeStore.ts @@ -266,6 +266,7 @@ export const useCreativeStore = create((set, get) => ({ generationProgress: 100, }; + useCanvasStore.getState().pushSnapshot(); useCanvasStore.setState((state) => ({ nodes: state.nodes.map((node) => { if (node.id === placeholderId) { @@ -378,8 +379,9 @@ export const useCreativeStore = create((set, get) => ({ data: cardData, }; + useCanvasStore.getState().pushSnapshot(); useCanvasStore.setState({ - nodes: [...existingNodes, newCardNode], + nodes: [...useCanvasStore.getState().nodes, newCardNode], selectedNodeId: cardId, }); useCanvasStore.getState().notifyCanvasChange(); diff --git a/frontend/src/tests/canvasTransactions.test.ts b/frontend/src/tests/canvasTransactions.test.ts new file mode 100644 index 0000000..6b876a0 --- /dev/null +++ b/frontend/src/tests/canvasTransactions.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Node } from '@xyflow/react'; +import { CanvasNodeData } from '../types/creative'; +import { useCanvasStore } from '../stores/useCanvasStore'; +import { useCreativeStore } from '../stores/useCreativeStore'; + +const card = (id: string): Node => ({ + id, type: 'imageCard', position: { x: 10, y: 20 }, + data: { assetId: id, imageUrl: `/api/v1/assets/${id}/content`, width: 8, height: 6 }, +}); + +describe('Complete canvas transactions', () => { + beforeEach(() => { + useCanvasStore.setState({ nodes: [card('a'), card('b')], edges: [{ id: 'ab', source: 'a', target: 'b' }], selectedNodeId: 'a', past: [], future: [], isDragging: false }); + vi.restoreAllMocks(); + }); + + it('restores deleted nodes, their relationships, edges and selection together', () => { + useCanvasStore.getState().removeNode('a'); + expect(useCanvasStore.getState().edges).toEqual([]); + useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(useCanvasStore.getState().edges[0].id).toBe('ab'); + expect(useCanvasStore.getState().selectedNodeId).toBe('a'); + useCanvasStore.getState().redo(); + expect(useCanvasStore.getState().nodes.map((node) => node.id)).toEqual(['b']); + expect(useCanvasStore.getState().edges).toEqual([]); + expect(useCanvasStore.getState().selectedNodeId).toBeNull(); + }); + + it('coalesces a drag gesture and restores its initial position', () => { + for (const x of [20, 30, 40]) useCanvasStore.getState().onNodesChange([{ id: 'a', type: 'position', position: { x, y: 20 }, dragging: true }]); + useCanvasStore.getState().onNodesChange([{ id: 'a', type: 'position', position: { x: 40, y: 20 }, dragging: false }]); + expect(useCanvasStore.getState().past).toHaveLength(1); + useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes[0].position.x).toBe(10); + useCanvasStore.getState().redo(); + expect(useCanvasStore.getState().nodes[0].position.x).toBe(40); + }); + + it('creates one transaction when ReactFlow removes nodes then incident edges', () => { + useCanvasStore.getState().onNodesChange([{ id: 'a', type: 'remove' }]); + useCanvasStore.getState().onEdgesChange([{ id: 'ab', type: 'remove' }]); + expect(useCanvasStore.getState().past).toHaveLength(1); + useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().edges).toHaveLength(1); + }); + + it('undoes an imported card without losing existing graph data', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: 'import', filename: 'image.png', width: 8, height: 6 }) })); + await useCreativeStore.getState().uploadCanvasImage(new File(['fixture'], 'image.png', { type: 'image/png' })); + expect(useCanvasStore.getState().nodes).toHaveLength(3); + useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes).toHaveLength(2); + expect(useCanvasStore.getState().edges).toHaveLength(1); + useCanvasStore.getState().redo(); + expect(useCanvasStore.getState().nodes).toHaveLength(3); + vi.unstubAllGlobals(); + }); + + it('never restores a pending generation placeholder from history', () => { + const pending = card('pending'); + pending.data = { ...pending.data, isGenerating: true }; + useCanvasStore.setState({ nodes: [...useCanvasStore.getState().nodes, pending] }); + useCanvasStore.getState().pushSnapshot(); + useCanvasStore.setState({ nodes: [card('done')] }); + useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes.map((node) => node.id)).toEqual(['a', 'b']); + }); + + it('undoes and redoes a completed generation as a single insertion', async () => { + useCreativeStore.setState({ referenceImage: null, isGenerating: false }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, task_id: 'task', asset_id: 'result', image_url: '/api/v1/assets/result/content', width: 8, height: 6 }) })); + await useCreativeStore.getState().executeCreativeAction(); + expect(useCanvasStore.getState().nodes).toHaveLength(3); + expect(useCanvasStore.getState().past).toHaveLength(1); + useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes.map((node) => node.id)).toEqual(['a', 'b']); + useCanvasStore.getState().redo(); + expect(useCanvasStore.getState().nodes[2].data.assetId).toBe('result'); + vi.unstubAllGlobals(); + }); +});