diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d46f8e4..f0d8f64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,7 +53,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() + const { loadCanvas, applyLayout, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -498,9 +498,11 @@ export default function App() { const handleAutoLayout = useCallback(() => { const laid = applyDagreLayout(nodes, edges) - loadCanvas(laid, edges) + // applyLayout keeps undo history so the user can revert an accidental + // Auto Layout (#280); loadCanvas would wipe it. + applyLayout(laid, edges) toast.success('Canvas auto-arranged') - }, [nodes, edges, loadCanvas]) + }, [nodes, edges, applyLayout]) const handleExportMd = useCallback(async () => { const md = generateMarkdownTable(nodes) @@ -522,14 +524,14 @@ export default function App() { const handleImportYaml = useCallback((content: string) => { try { const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges) - snapshotHistory() - loadCanvas(merged, mergedEdges) - markUnsaved() + // applyLayout keeps undo history so an import can be reverted; loadCanvas + // would wipe it (#280). + applyLayout(merged, mergedEdges) toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`) } catch (err) { toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`) } - }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) + }, [nodes, edges, applyLayout]) // Open the read-only live view of the currently active design in a new tab. // Standalone has no backend/key — it reads localStorage, so just open /view. diff --git a/frontend/src/stores/__tests__/canvasStore/history.test.ts b/frontend/src/stores/__tests__/canvasStore/history.test.ts index 57041d4..3995522 100644 --- a/frontend/src/stores/__tests__/canvasStore/history.test.ts +++ b/frontend/src/stores/__tests__/canvasStore/history.test.ts @@ -85,4 +85,31 @@ describe('canvasStore — history', () => { addNode(makeNode('n3')) expect(useCanvasStore.getState().future).toHaveLength(0) }) + + // --- Auto Layout keeps history so it can be undone (#280) --- + + it('applyLayout snapshots history and can be undone', () => { + const { addNode, applyLayout, undo } = useCanvasStore.getState() + addNode(makeNode('n1')) + const before = useCanvasStore.getState().nodes + // simulate an auto-layout that moves the node + applyLayout([{ ...before[0], position: { x: 500, y: 500 } }], []) + expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 500, y: 500 }) + expect(useCanvasStore.getState().past).toHaveLength(1) + undo() + expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 0, y: 0 }) + }) + + it('applyLayout marks the canvas unsaved and clears future', () => { + const { addNode, snapshotHistory, undo, applyLayout } = useCanvasStore.getState() + addNode(makeNode('n1')) + snapshotHistory() + addNode(makeNode('n2')) + undo() // creates a future entry + expect(useCanvasStore.getState().future).toHaveLength(1) + applyLayout(useCanvasStore.getState().nodes, []) + const s = useCanvasStore.getState() + expect(s.hasUnsavedChanges).toBe(true) + expect(s.future).toHaveLength(0) + }) }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 109de53..419c3b1 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -147,6 +147,10 @@ interface CanvasState { markSaved: () => void markUnsaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void + /** In-place canvas replacement (e.g. Auto Layout) that KEEPS undo history and + * marks the canvas unsaved. Unlike loadCanvas, it does not wipe past/future — + * loadCanvas is for switching designs, this is for transforming the current one. */ + applyLayout: (nodes: Node[], edges: Edge[]) => void fitViewPending: boolean clearFitViewPending: () => void notifyScanDeviceFound: () => void @@ -870,6 +874,22 @@ export const useCanvasStore = create((set) => ({ set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true }) }, + applyLayout: (nodes, edges) => + set((state) => { + // React Flow requires parents before children in the array + const parents = nodes.filter((n) => !n.parentId) + const children = nodes.filter((n) => !!n.parentId) + return { + nodes: [...parents, ...children], + edges, + past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], + future: [], + hasUnsavedChanges: true, + selectedNodeId: null, + fitViewPending: true, + } + }), + clearFitViewPending: () => set({ fitViewPending: false }), applyTypeNodeStyle: (nodeType, style) =>