feat: canvas history (undo/redo), copy/paste nodes, node search, shortcuts modal
- 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
This commit is contained in:
+49
-14
@@ -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<HTMLDivElement>(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<string | null>(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<NodeData>) => {
|
||||
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<NodeData> = {
|
||||
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<NodeData>) => {
|
||||
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<EdgeData>) => {
|
||||
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)}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
|
||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStop={snapshotHistory} />
|
||||
</div>
|
||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||
</div>
|
||||
@@ -497,6 +529,9 @@ export default function App() {
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -20,9 +20,10 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
interface CanvasContainerProps {
|
||||
onConnect?: (connection: Connection) => void
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => 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
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" onClick={onClose}>
|
||||
<div
|
||||
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||
<Search size={16} className="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => 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)
|
||||
}}
|
||||
/>
|
||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ul className="py-1 max-h-64 overflow-y-auto">
|
||||
{results.map((node) => (
|
||||
<li
|
||||
key={node.id}
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelect(node.id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||
{node.data.ip && (
|
||||
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{q.length > 0 && results.length === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
||||
)}
|
||||
|
||||
{q.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div
|
||||
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{SHORTCUTS.map((group) => (
|
||||
<div key={group.group}>
|
||||
<p className="text-xs text-[#00d4ff] font-semibold mb-2 uppercase tracking-wide">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.description} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{item.description}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{item.keys.map((k, i) => (
|
||||
k === '+' ? (
|
||||
<span key={i} className="text-xs text-muted-foreground">+</span>
|
||||
) : (
|
||||
<kbd key={k} className="text-[11px] text-foreground border border-border rounded px-1.5 py-0.5 font-mono bg-[#0d1117]">
|
||||
{k}
|
||||
</kbd>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<ShortcutsModal open={false} onClose={vi.fn()} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders shortcut groups when open', () => {
|
||||
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||
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(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||
fireEvent.click(container.firstChild as HTMLElement)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose when X button clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||
<Logo size={28} showText={true} />
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
onClick={onUndo}
|
||||
disabled={past.length === 0}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
onClick={onRedo}
|
||||
disabled={future.length === 0}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||
<LayoutDashboard size={14} /> Auto Layout
|
||||
</Button>
|
||||
@@ -26,6 +48,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: Toolb
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
|
||||
<Download size={14} /> Export
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||
<HelpCircle size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 relative"
|
||||
|
||||
@@ -26,6 +26,9 @@ describe('canvasStore', () => {
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
editingGroupRectId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -234,4 +237,95 @@ describe('canvasStore', () => {
|
||||
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
||||
expect(parentIdx).toBeLessThan(childIdx)
|
||||
})
|
||||
|
||||
// --- History (undo/redo) ---
|
||||
|
||||
it('snapshotHistory pushes current state to past and clears future', () => {
|
||||
const { addNode, snapshotHistory } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
const { past, future } = useCanvasStore.getState()
|
||||
expect(past).toHaveLength(1)
|
||||
expect(past[0].nodes).toHaveLength(1)
|
||||
expect(future).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('undo restores previous state and moves current to future', () => {
|
||||
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n2'))
|
||||
undo()
|
||||
const { nodes, past, future } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0].id).toBe('n1')
|
||||
expect(past).toHaveLength(0)
|
||||
expect(future).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('redo re-applies undone state', () => {
|
||||
const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n2'))
|
||||
undo()
|
||||
redo()
|
||||
const { nodes, future } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(future).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('undo does nothing when past is empty', () => {
|
||||
const { addNode, undo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
undo()
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('snapshotHistory clears future (new branch)', () => {
|
||||
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n2'))
|
||||
undo()
|
||||
// now take a new action
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n3'))
|
||||
expect(useCanvasStore.getState().future).toHaveLength(0)
|
||||
})
|
||||
|
||||
// --- Clipboard (copy/paste) ---
|
||||
|
||||
it('copySelectedNodes stores only selected nodes', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{ ...makeNode('a'), selected: true },
|
||||
{ ...makeNode('b'), selected: false },
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
const { clipboard } = useCanvasStore.getState()
|
||||
expect(clipboard).toHaveLength(1)
|
||||
expect(clipboard[0].id).toBe('a')
|
||||
})
|
||||
|
||||
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
||||
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
||||
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(2)
|
||||
const pasted = nodes.find((n) => n.id !== 'src')!
|
||||
expect(pasted).toBeDefined()
|
||||
expect(pasted.position.x).toBe(150)
|
||||
expect(pasted.position.y).toBe(150)
|
||||
expect(pasted.selected).toBe(false)
|
||||
})
|
||||
|
||||
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
|
||||
interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
edges: Edge<EdgeData>[]
|
||||
@@ -18,6 +20,18 @@ interface CanvasState {
|
||||
selectedNodeId: string | null
|
||||
scanEventTs: number
|
||||
|
||||
// History
|
||||
past: HistoryEntry[]
|
||||
future: HistoryEntry[]
|
||||
snapshotHistory: () => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
|
||||
// Clipboard
|
||||
clipboard: Node<NodeData>[]
|
||||
copySelectedNodes: () => void
|
||||
pasteNodes: () => void
|
||||
|
||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||
onConnect: (connection: Connection) => void
|
||||
@@ -48,6 +62,67 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
hideIp: false,
|
||||
scanEventTs: 0,
|
||||
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
|
||||
snapshotHistory: () =>
|
||||
set((state) => ({
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
})),
|
||||
|
||||
undo: () =>
|
||||
set((state) => {
|
||||
if (state.past.length === 0) return state
|
||||
const previous = state.past[state.past.length - 1]
|
||||
return {
|
||||
nodes: previous.nodes,
|
||||
edges: previous.edges,
|
||||
past: state.past.slice(0, -1),
|
||||
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
}),
|
||||
|
||||
redo: () =>
|
||||
set((state) => {
|
||||
if (state.future.length === 0) return state
|
||||
const next = state.future[0]
|
||||
return {
|
||||
nodes: next.nodes,
|
||||
edges: next.edges,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: state.future.slice(1),
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
}),
|
||||
|
||||
copySelectedNodes: () =>
|
||||
set((state) => ({
|
||||
clipboard: state.nodes.filter((n) => n.selected),
|
||||
})),
|
||||
|
||||
pasteNodes: () =>
|
||||
set((state) => {
|
||||
if (state.clipboard.length === 0) return state
|
||||
const newNodes = state.clipboard.map((n) => ({
|
||||
...n,
|
||||
id: crypto.randomUUID(),
|
||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||
selected: false,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
data: { ...n.data, parent_id: undefined },
|
||||
}))
|
||||
return {
|
||||
nodes: [...state.nodes, ...newNodes],
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
}),
|
||||
|
||||
onNodesChange: (changes) =>
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes),
|
||||
|
||||
Reference in New Issue
Block a user