fix(canvas): allow undo after Auto Layout and YAML import

Auto Layout and YAML import routed through loadCanvas, which wipes
past/future history and clears the unsaved flag (design-switch
semantics). Undo was therefore impossible after either action (#280).

Add an in-place applyLayout store action that snapshots history, marks
the canvas unsaved and re-fits the view without discarding undo history,
and wire both Auto Layout and YAML import to it.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-16 19:29:41 +02:00
parent 6ae8b91934
commit ba4f8b0be4
3 changed files with 56 additions and 7 deletions
+9 -7
View File
@@ -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<HTMLDivElement>(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.
@@ -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)
})
})
+20
View File
@@ -147,6 +147,10 @@ interface CanvasState {
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => 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<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void
@@ -870,6 +874,22 @@ export const useCanvasStore = create<CanvasState>((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) =>