From ba032a45afa1eea241f4cffecb75323f8476d6ac Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 12 Mar 2026 11:56:38 +0100 Subject: [PATCH] feat: canvas history (undo/redo), copy/paste nodes, node search, shortcuts modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Undo/Redo (Ctrl+Z / Ctrl+Y): 50-entry snapshot stack in canvasStore; snapshot before all mutations and on node drag stop - Copy/Paste (Ctrl+C / Ctrl+V): copy selected nodes to clipboard, paste with +50px offset and new IDs - Node search (Ctrl+K): spotlight overlay — fuzzy search by label/IP/hostname, jumps + focuses matched node - Shortcuts modal (?): lists all keyboard shortcuts, accessible via ? key or toolbar ? button - Toolbar: undo/redo buttons (disabled when stack empty), ? help button --- frontend/src/App.tsx | 63 ++++++++++--- .../src/components/canvas/CanvasContainer.tsx | 4 +- .../src/components/modals/SearchModal.tsx | 84 +++++++++++++++++ .../src/components/modals/ShortcutsModal.tsx | 84 +++++++++++++++++ .../modals/__tests__/ShortcutsModal.test.tsx | 38 ++++++++ frontend/src/components/panels/Toolbar.tsx | 31 +++++- .../src/stores/__tests__/canvasStore.test.ts | 94 +++++++++++++++++++ frontend/src/stores/canvasStore.ts | 75 +++++++++++++++ 8 files changed, 455 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/modals/SearchModal.tsx create mode 100644 frontend/src/components/modals/ShortcutsModal.tsx create mode 100644 frontend/src/components/modals/__tests__/ShortcutsModal.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d3e3ce..1b5c84d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,8 @@ import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { ThemeModal } from '@/components/modals/ThemeModal' +import { SearchModal } from '@/components/modals/SearchModal' +import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' @@ -28,7 +30,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore() + const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme } = useThemeStore() @@ -36,6 +38,8 @@ export default function App() { useStatusPolling() const [themeModalOpen, setThemeModalOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [shortcutsOpen, setShortcutsOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) @@ -201,19 +205,38 @@ export default function App() { .catch(() => loadCanvas(demoNodes, demoEdges)) }, [isAuthenticated, loadCanvas, setTheme]) - // Ctrl+S + // Keep refs for store actions so keydown handler is always up-to-date without re-registering + const undoRef = useRef(undo) + const redoRef = useRef(redo) + const copyRef = useRef(copySelectedNodes) + const pasteRef = useRef(pasteNodes) + useEffect(() => { undoRef.current = undo }, [undo]) + useEffect(() => { redoRef.current = redo }, [redo]) + useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes]) + useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes]) + + // Global keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 's') { - e.preventDefault() - handleSaveRef.current() - } + const ctrl = e.ctrlKey || e.metaKey + // Ignore shortcuts when typing in an input/textarea + const tag = (e.target as HTMLElement).tagName + const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable + + if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return } + if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return } + if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return } + if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return } + if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return } + if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return } + if (e.key === '?' && !isInput) { setShortcutsOpen(true); return } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, []) const handleAddNode = useCallback((data: Partial) => { + snapshotHistory() const id = crypto.randomUUID() const isProxmox = data.type === 'proxmox' const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null @@ -232,9 +255,10 @@ export default function App() { } addNode(newNode) toast.success(`Added "${data.label}"`) - }, [addNode, nodes]) + }, [addNode, nodes, snapshotHistory]) const handleAddGroupRect = useCallback((data: GroupRectFormData) => { + snapshotHistory() const id = crypto.randomUUID() const newNode: Node = { id, @@ -259,7 +283,7 @@ export default function App() { zIndex: data.z_order - 10, } addNode(newNode) - }, [addNode]) + }, [addNode, snapshotHistory]) const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { if (!editingGroupRectId) return @@ -282,9 +306,10 @@ export default function App() { const handleDeleteGroupRect = useCallback(() => { if (!editingGroupRectId) return + snapshotHistory() deleteNode(editingGroupRectId) setEditingGroupRectId(null) - }, [editingGroupRectId, deleteNode, setEditingGroupRectId]) + }, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory]) const handleEditNode = useCallback((id: string) => { setEditNodeId(id) @@ -292,6 +317,7 @@ export default function App() { const handleUpdateNode = useCallback((data: Partial) => { if (!editNodeId) return + snapshotHistory() const existingNode = nodes.find((n) => n.id === editNodeId) updateNode(editNodeId, data) // If proxmox container_mode changed, apply structural changes (children parentId, node dimensions) @@ -321,7 +347,7 @@ export default function App() { } } setEditNodeId(null) - }, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect]) + }, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory]) const handleAutoLayout = useCallback(() => { const laid = applyDagreLayout(nodes, edges) @@ -346,6 +372,7 @@ export default function App() { const handleEdgeConfirm = useCallback((edgeData: EdgeData) => { if (!pendingConnection) return + snapshotHistory() onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection) // When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id if (edgeData.type === 'virtual') { @@ -360,7 +387,7 @@ export default function App() { } } setPendingConnection(null) - }, [pendingConnection, onConnect, nodes, updateNode]) + }, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory]) const handleEdgeDoubleClick = useCallback((edge: Edge) => { setEditEdgeId(edge.id) @@ -368,15 +395,17 @@ export default function App() { const handleEdgeUpdate = useCallback((data: EdgeData) => { if (!editEdgeId) return + snapshotHistory() updateEdge(editEdgeId, data) setEditEdgeId(null) - }, [editEdgeId, updateEdge]) + }, [editEdgeId, updateEdge, snapshotHistory]) const handleEdgeDelete = useCallback(() => { if (!editEdgeId) return + snapshotHistory() deleteEdge(editEdgeId) setEditEdgeId(null) - }, [editEdgeId, deleteEdge]) + }, [editEdgeId, deleteEdge, snapshotHistory]) const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null @@ -400,10 +429,13 @@ export default function App() { onAutoLayout={handleAutoLayout} onExport={handleExport} onChangeStyle={() => setThemeModalOpen(true)} + onUndo={undo} + onRedo={redo} + onShortcuts={() => setShortcutsOpen(true)} />
- +
{selectedNodeId && }
@@ -497,6 +529,9 @@ export default function App() { onClose={() => setThemeModalOpen(false)} /> + setSearchOpen(false)} /> + setShortcutsOpen(false)} /> + diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index f6bc77c..77ba6c5 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -20,9 +20,10 @@ import type { NodeData, EdgeData } from '@/types' interface CanvasContainerProps { onConnect?: (connection: Connection) => void onEdgeDoubleClick?: (edge: Edge) => void + onNodeDragStop?: () => void } -export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) { +export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStop }: CanvasContainerProps) { const { nodes, edges, onNodesChange, onEdgesChange, @@ -55,6 +56,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: onNodeClick={onNodeClick} onPaneClick={onPaneClick} onEdgeDoubleClick={handleEdgeDoubleClick} + onNodeDragStop={onNodeDragStop} nodeTypes={nodeTypes} edgeTypes={edgeTypes} snapToGrid diff --git a/frontend/src/components/modals/SearchModal.tsx b/frontend/src/components/modals/SearchModal.tsx new file mode 100644 index 0000000..5ae862c --- /dev/null +++ b/frontend/src/components/modals/SearchModal.tsx @@ -0,0 +1,84 @@ +import { useState, useCallback } from 'react' +import { useReactFlow } from '@xyflow/react' +import { Search } from 'lucide-react' +import { useCanvasStore } from '@/stores/canvasStore' + +interface SearchModalProps { + open: boolean + onClose: () => void +} + +export function SearchModal({ open, onClose }: SearchModalProps) { + const [query, setQuery] = useState('') + const nodes = useCanvasStore((s) => s.nodes) + const setSelectedNode = useCanvasStore((s) => s.setSelectedNode) + const { fitView } = useReactFlow() + + const searchable = nodes.filter((n) => n.data.type !== 'groupRect') + const q = query.toLowerCase() + const results = q.length === 0 ? [] : searchable.filter((n) => + n.data.label?.toLowerCase().includes(q) || + n.data.ip?.toLowerCase().includes(q) || + n.data.hostname?.toLowerCase().includes(q) + ).slice(0, 8) + + const handleSelect = useCallback((nodeId: string) => { + setSelectedNode(nodeId) + fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 }) + onClose() + setQuery('') + }, [fitView, setSelectedNode, onClose]) + + if (!open) return null + + return ( +
+
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + placeholder="Search nodes by label, IP, hostname…" + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + onKeyDown={(e) => { + if (e.key === 'Escape') { onClose(); setQuery('') } + if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id) + }} + /> + ESC +
+ + {results.length > 0 && ( +
    + {results.map((node) => ( +
  • handleSelect(node.id)} + > + {node.data.type} + {node.data.label} + {node.data.ip && ( + {node.data.ip} + )} +
  • + ))} +
+ )} + + {q.length > 0 && results.length === 0 && ( +

No nodes match "{query}"

+ )} + + {q.length === 0 && ( +

Type to search nodes…

+ )} +
+
+ ) +} diff --git a/frontend/src/components/modals/ShortcutsModal.tsx b/frontend/src/components/modals/ShortcutsModal.tsx new file mode 100644 index 0000000..961ab5d --- /dev/null +++ b/frontend/src/components/modals/ShortcutsModal.tsx @@ -0,0 +1,84 @@ +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' + +const SHORTCUTS = [ + { + group: 'Canvas', + items: [ + { keys: ['Ctrl', 'S'], description: 'Save canvas' }, + { keys: ['Ctrl', 'Z'], description: 'Undo' }, + { keys: ['Ctrl', 'Y'], description: 'Redo' }, + { keys: ['Ctrl', 'K'], description: 'Search nodes' }, + { keys: ['?'], description: 'Show this help' }, + ], + }, + { + group: 'Nodes', + items: [ + { keys: ['Ctrl', 'C'], description: 'Copy selected nodes' }, + { keys: ['Ctrl', 'V'], description: 'Paste nodes' }, + { keys: ['Del'], description: 'Delete selected node/edge' }, + ], + }, + { + group: 'Navigation', + items: [ + { keys: ['Scroll'], description: 'Zoom in / out' }, + { keys: ['Space', '+', 'Drag'], description: 'Pan canvas' }, + { keys: ['Ctrl', 'Shift', 'F'], description: 'Fit view' }, + ], + }, +] + +interface ShortcutsModalProps { + open: boolean + onClose: () => void +} + +export function ShortcutsModal({ open, onClose }: ShortcutsModalProps) { + if (!open) return null + + return ( +
+
e.stopPropagation()} + > +
+

Keyboard Shortcuts

+ +
+ +
+ {SHORTCUTS.map((group) => ( +
+

+ {group.group} +

+
+ {group.items.map((item) => ( +
+ {item.description} +
+ {item.keys.map((k, i) => ( + k === '+' ? ( + + + ) : ( + + {k} + + ) + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/ShortcutsModal.test.tsx b/frontend/src/components/modals/__tests__/ShortcutsModal.test.tsx new file mode 100644 index 0000000..035144b --- /dev/null +++ b/frontend/src/components/modals/__tests__/ShortcutsModal.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { ShortcutsModal } from '../ShortcutsModal' + +describe('ShortcutsModal', () => { + it('renders nothing when closed', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders shortcut groups when open', () => { + render() + expect(screen.getByText('Keyboard Shortcuts')).toBeDefined() + expect(screen.getByText('Canvas')).toBeDefined() + expect(screen.getByText('Nodes')).toBeDefined() + expect(screen.getByText('Navigation')).toBeDefined() + }) + + it('shows key shortcuts in kbd elements', () => { + render() + expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0) + }) + + it('calls onClose when backdrop clicked', () => { + const onClose = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild as HTMLElement) + expect(onClose).toHaveBeenCalled() + }) + + it('calls onClose when X button clicked', () => { + const onClose = vi.fn() + render() + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index ed00d7a..fba251a 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -1,4 +1,4 @@ -import { Save, LayoutDashboard, Download, Palette } from 'lucide-react' +import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' @@ -8,15 +8,37 @@ interface ToolbarProps { onAutoLayout: () => void onExport: () => void onChangeStyle: () => void + onUndo: () => void + onRedo: () => void + onShortcuts: () => void } -export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) { - const { hasUnsavedChanges } = useCanvasStore() +export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts }: ToolbarProps) { + const { hasUnsavedChanges, past, future } = useCanvasStore() return (
+ + +
@@ -26,6 +48,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: Toolb +