Merge pull request #189 from Pouzor/feat/cross-design-copy-paste
feat(canvas): cross-design copy/paste + persisted Hide-IP in Settings
This commit is contained in:
@@ -42,7 +42,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
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, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -213,12 +213,8 @@ export default function App() {
|
|||||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
const redoRef = useRef(redo)
|
const redoRef = useRef(redo)
|
||||||
const copyRef = useRef(copySelectedNodes)
|
|
||||||
const pasteRef = useRef(pasteNodes)
|
|
||||||
useEffect(() => { undoRef.current = undo }, [undo])
|
useEffect(() => { undoRef.current = undo }, [undo])
|
||||||
useEffect(() => { redoRef.current = redo }, [redo])
|
useEffect(() => { redoRef.current = redo }, [redo])
|
||||||
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
|
||||||
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,8 +228,8 @@ export default function App() {
|
|||||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.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 === '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 === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||||
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||||
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
// nodes under the cursor / viewport center.
|
||||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -40,8 +40,34 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode, snapshotHistory,
|
setSelectedNode, snapshotHistory,
|
||||||
fitViewPending, clearFitViewPending,
|
fitViewPending, clearFitViewPending,
|
||||||
|
copySelectedNodes, pasteNodes,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
// Track the last cursor position over the canvas so paste lands under it.
|
||||||
|
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
||||||
|
// can project the cursor / viewport center into flow coordinates.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable
|
||||||
|
if (isInput) return
|
||||||
|
if (e.key === 'c') {
|
||||||
|
copySelectedNodes()
|
||||||
|
} else if (e.key === 'v') {
|
||||||
|
const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||||
|
pasteNodes(screenToFlowPosition(screen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [copySelectedNodes, pasteNodes, screenToFlowPosition])
|
||||||
|
|
||||||
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,7 +126,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={visibleNodes}
|
nodes={visibleNodes}
|
||||||
edges={visibleEdges}
|
edges={visibleEdges}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { settingsApi } from '@/api/client'
|
import { settingsApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
type AlignmentSettings,
|
type AlignmentSettings,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
subscribeAlignmentSettings,
|
subscribeAlignmentSettings,
|
||||||
} from '@/utils/alignmentSettings'
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -19,9 +22,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open || STANDALONE) return
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
@@ -36,6 +41,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
||||||
|
// status-check interval needs an API round-trip.
|
||||||
|
if (STANDALONE) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await settingsApi.save({ interval_seconds: interval })
|
await settingsApi.save({ interval_seconds: interval })
|
||||||
@@ -57,6 +68,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
<div className="space-y-5 py-2">
|
<div className="space-y-5 py-2">
|
||||||
{/* Status checker */}
|
{/* Status checker */}
|
||||||
|
{!STANDALONE && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -74,6 +86,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
How often node health is polled (ping, HTTP, SSH…)
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div className="pt-3 border-t border-border space-y-3">
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
@@ -90,6 +103,17 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Hide IP addresses</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideIp}
|
||||||
|
onChange={(e) => setHideIp(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle IP address masking"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
|
|
||||||
import { settingsApi } from '@/api/client'
|
import { settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
describe('SettingsModal', () => {
|
describe('SettingsModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -64,6 +65,17 @@ describe('SettingsModal', () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled()
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reflects and persists the hide-IP preference', async () => {
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
const checkbox = screen.getByLabelText('Toggle IP address masking') as HTMLInputElement
|
||||||
|
expect(checkbox.checked).toBe(false)
|
||||||
|
fireEvent.click(checkbox)
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onClose on Cancel', async () => {
|
it('calls onClose on Cancel', async () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SettingsModal open onClose={onClose} />)
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -90,7 +90,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
@@ -253,13 +253,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
|
||||||
icon={hideIp ? EyeOff : Eye}
|
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={toggleHideIp}
|
|
||||||
active={hideIp}
|
|
||||||
/>
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
@@ -268,14 +261,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
badge={hasUnsavedChanges}
|
badge={hasUnsavedChanges}
|
||||||
accent
|
accent
|
||||||
/>
|
/>
|
||||||
{!STANDALONE && (
|
<SidebarItem
|
||||||
<SidebarItem
|
icon={Settings}
|
||||||
icon={Settings}
|
label="Settings"
|
||||||
label="Settings"
|
collapsed={collapsed}
|
||||||
collapsed={collapsed}
|
onClick={onOpenSettings}
|
||||||
onClick={onOpenSettings}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={LogOut}
|
icon={LogOut}
|
||||||
|
|||||||
@@ -46,15 +46,12 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
|||||||
data: { label: id, type, status, services: [] },
|
data: { label: id, type, status, services: [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockToggleHideIp = vi.fn()
|
|
||||||
const mockLogout = vi.fn()
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
hideIp: false,
|
|
||||||
toggleHideIp: mockToggleHideIp,
|
|
||||||
addNode: vi.fn(),
|
addNode: vi.fn(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -191,16 +188,10 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls toggleHideIp when Hide IPs is clicked', () => {
|
it('calls onOpenSettings when Settings is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hide IPs'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
expect(mockToggleHideIp).toHaveBeenCalledOnce()
|
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Show IPs label when hideIp is true', () => {
|
|
||||||
mockStore({ hideIp: true })
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
|
||||||
expect(screen.getByText('Show IPs')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('canvasStore', () => {
|
|||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: { nodes: [], edges: [] },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -690,29 +690,136 @@ describe('canvasStore', () => {
|
|||||||
})
|
})
|
||||||
useCanvasStore.getState().copySelectedNodes()
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
const { clipboard } = useCanvasStore.getState()
|
const { clipboard } = useCanvasStore.getState()
|
||||||
expect(clipboard).toHaveLength(1)
|
expect(clipboard.nodes).toHaveLength(1)
|
||||||
expect(clipboard[0].id).toBe('a')
|
expect(clipboard.nodes[0].id).toBe('a')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
it('copySelectedNodes captures edges whose endpoints are both selected', () => {
|
||||||
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
useCanvasStore.setState({
|
||||||
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
nodes: [
|
||||||
|
{ ...makeNode('a'), selected: true },
|
||||||
|
{ ...makeNode('b'), selected: true },
|
||||||
|
{ ...makeNode('c'), selected: false },
|
||||||
|
],
|
||||||
|
edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
const { clipboard } = useCanvasStore.getState()
|
||||||
|
expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b'])
|
||||||
|
expect(clipboard.edges).toHaveLength(1)
|
||||||
|
expect(clipboard.edges[0].id).toBe('e-ab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copySelectedNodes pulls in children of a selected group', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ ...makeNode('g', { type: 'group' }), type: 'group', selected: true },
|
||||||
|
{ ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 100, y: 100 } }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
|
||||||
useCanvasStore.getState().pasteNodes()
|
useCanvasStore.getState().pasteNodes()
|
||||||
const { nodes } = useCanvasStore.getState()
|
const { nodes } = useCanvasStore.getState()
|
||||||
expect(nodes).toHaveLength(2)
|
expect(nodes).toHaveLength(1)
|
||||||
const pasted = nodes.find((n) => n.id !== 'src')!
|
const pasted = nodes[0]
|
||||||
expect(pasted).toBeDefined()
|
expect(pasted.id).not.toBe('src')
|
||||||
expect(pasted.position.x).toBe(150)
|
expect(pasted.position).toEqual({ x: 150, y: 150 })
|
||||||
expect(pasted.position.y).toBe(150)
|
expect(pasted.selected).toBe(true)
|
||||||
expect(pasted.selected).toBe(false)
|
})
|
||||||
|
|
||||||
|
it('pasteNodes centers the pasted bounding box on the target point', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
|
||||||
|
useCanvasStore.getState().pasteNodes({ x: 500, y: 300 })
|
||||||
|
const pasted = useCanvasStore.getState().nodes[0]
|
||||||
|
// bbox center (50,50) shifted onto (500,300) → top-left at (450,250)
|
||||||
|
expect(pasted.position).toEqual({ x: 450, y: 250 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes remaps edge endpoints to the new node IDs', () => {
|
||||||
|
const a = { ...makeNode('a') }
|
||||||
|
const b = { ...makeNode('b') }
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] },
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes, edges } = useCanvasStore.getState()
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
const ids = nodes.map((n) => n.id)
|
||||||
|
expect(ids).toContain(edges[0].source)
|
||||||
|
expect(ids).toContain(edges[0].target)
|
||||||
|
expect(edges[0].source).not.toBe('a')
|
||||||
|
expect(edges[0].id).not.toBe('e-ab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes preserves parent-child relationship under remapped IDs', () => {
|
||||||
|
const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const newGroup = nodes.find((n) => n.data.type === 'group')!
|
||||||
|
const newChild = nodes.find((n) => n.id !== newGroup.id)!
|
||||||
|
expect(newChild.parentId).toBe(newGroup.id)
|
||||||
|
expect(newChild.data.parent_id).toBe(newGroup.id)
|
||||||
|
// Child keeps its parent-relative position (no offset applied to children)
|
||||||
|
expect(newChild.position).toEqual({ x: 20, y: 30 })
|
||||||
|
// Group (the root) precedes its child in the array
|
||||||
|
expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan(
|
||||||
|
nodes.findIndex((n) => n.id === newChild.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clipboard survives loadCanvas so nodes can be pasted into another design', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [{ ...makeNode('a'), selected: true }],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
// Switch to another design: loadCanvas replaces nodes/edges.
|
||||||
|
useCanvasStore.getState().loadCanvas([makeNode('other')], [])
|
||||||
|
expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1)
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
|
||||||
|
expect(ids).toContain('other')
|
||||||
|
expect(ids).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pasteNodes does nothing when clipboard is empty', () => {
|
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } })
|
||||||
useCanvasStore.getState().pasteNodes()
|
useCanvasStore.getState().pasteNodes()
|
||||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Hide IP preference (persisted to localStorage) ---
|
||||||
|
|
||||||
|
it('toggleHideIp flips the flag and persists it', () => {
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
useCanvasStore.getState().toggleHideIp()
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
useCanvasStore.getState().toggleHideIp()
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(false)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setHideIp sets the flag and persists it', () => {
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
useCanvasStore.getState().setHideIp(true)
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
// --- Node resizing (width / height) ---
|
// --- Node resizing (width / height) ---
|
||||||
|
|
||||||
it('addNode preserves explicit width and height', () => {
|
it('addNode preserves explicit width and height', () => {
|
||||||
@@ -867,7 +974,7 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: { nodes: [], edges: [] },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty
|
|||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
|
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||||
|
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
@@ -31,10 +36,12 @@ interface CanvasState {
|
|||||||
undo: () => void
|
undo: () => void
|
||||||
redo: () => void
|
redo: () => void
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard — survives design switches so nodes can be pasted into another design
|
||||||
clipboard: Node<NodeData>[]
|
clipboard: Clipboard
|
||||||
copySelectedNodes: () => void
|
copySelectedNodes: () => void
|
||||||
pasteNodes: () => void
|
/** Paste clipboard into the current canvas. `center` (flow coords) lands the
|
||||||
|
* pasted bounding-box center under the cursor / viewport center. */
|
||||||
|
pasteNodes: (center?: { x: number; y: number }) => void
|
||||||
|
|
||||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
@@ -63,6 +70,7 @@ interface CanvasState {
|
|||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
|
setHideIp: (value: boolean) => void
|
||||||
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||||
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||||
@@ -76,13 +84,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
hideIp: false,
|
hideIp: readHideIp(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
|
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: { nodes: [], edges: [] },
|
||||||
|
|
||||||
snapshotHistory: () =>
|
snapshotHistory: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -117,24 +125,100 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
copySelectedNodes: () =>
|
copySelectedNodes: () =>
|
||||||
set((state) => ({
|
|
||||||
clipboard: state.nodes.filter((n) => n.selected),
|
|
||||||
})),
|
|
||||||
|
|
||||||
pasteNodes: () =>
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.clipboard.length === 0) return state
|
// Start from explicitly selected nodes, then pull in all descendants so a
|
||||||
const newNodes = state.clipboard.map((n) => ({
|
// copied group / container brings its children along.
|
||||||
...n,
|
const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id))
|
||||||
|
if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } }
|
||||||
|
let grew = true
|
||||||
|
while (grew) {
|
||||||
|
grew = false
|
||||||
|
for (const n of state.nodes) {
|
||||||
|
const pid = parentIdOf(n)
|
||||||
|
if (pid && ids.has(pid) && !ids.has(n.id)) {
|
||||||
|
ids.add(n.id)
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nodes = state.nodes.filter((n) => ids.has(n.id))
|
||||||
|
// Keep only edges whose both endpoints are inside the copied set.
|
||||||
|
const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
|
||||||
|
return { clipboard: { nodes, edges } }
|
||||||
|
}),
|
||||||
|
|
||||||
|
pasteNodes: (center) =>
|
||||||
|
set((state) => {
|
||||||
|
const clip = state.clipboard
|
||||||
|
if (clip.nodes.length === 0) return state
|
||||||
|
|
||||||
|
// Fresh ids for every copied node; edges/parent links are remapped through it.
|
||||||
|
const idMap = new Map<string, string>()
|
||||||
|
clip.nodes.forEach((n) => idMap.set(n.id, generateUUID()))
|
||||||
|
|
||||||
|
// A "root" is a copied node whose parent was not also copied — these carry
|
||||||
|
// absolute positions and receive the paste offset; children move with them.
|
||||||
|
const isRoot = (n: Node<NodeData>) => {
|
||||||
|
const pid = parentIdOf(n)
|
||||||
|
return !pid || !idMap.has(pid)
|
||||||
|
}
|
||||||
|
const roots = clip.nodes.filter(isRoot)
|
||||||
|
|
||||||
|
// Default cascade offset; when a target center is given, shift the root
|
||||||
|
// bounding-box center onto it instead.
|
||||||
|
let offsetX = 50
|
||||||
|
let offsetY = 50
|
||||||
|
if (center && roots.length > 0) {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||||
|
for (const n of roots) {
|
||||||
|
const w = n.width ?? n.measured?.width ?? 200
|
||||||
|
const h = n.height ?? n.measured?.height ?? 80
|
||||||
|
minX = Math.min(minX, n.position.x)
|
||||||
|
minY = Math.min(minY, n.position.y)
|
||||||
|
maxX = Math.max(maxX, n.position.x + w)
|
||||||
|
maxY = Math.max(maxY, n.position.y + h)
|
||||||
|
}
|
||||||
|
offsetX = center.x - (minX + maxX) / 2
|
||||||
|
offsetY = center.y - (minY + maxY) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasted = clip.nodes.map((n) => {
|
||||||
|
const root = isRoot(n)
|
||||||
|
const newParentId = root ? undefined : idMap.get(parentIdOf(n)!)
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
id: idMap.get(n.id)!,
|
||||||
|
position: root
|
||||||
|
? { x: n.position.x + offsetX, y: n.position.y + offsetY }
|
||||||
|
: { ...n.position },
|
||||||
|
selected: true,
|
||||||
|
parentId: newParentId,
|
||||||
|
extent: newParentId ? ('parent' as const) : undefined,
|
||||||
|
data: { ...n.data, parent_id: newParentId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pastedEdges = clip.edges.map((e) => ({
|
||||||
|
...e,
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
source: idMap.get(e.source)!,
|
||||||
|
target: idMap.get(e.target)!,
|
||||||
selected: false,
|
selected: false,
|
||||||
parentId: undefined,
|
|
||||||
extent: undefined,
|
|
||||||
data: { ...n.data, parent_id: undefined },
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// React Flow requires parents before children within the appended block.
|
||||||
|
const parents = pasted.filter((n) => !n.parentId)
|
||||||
|
const children = pasted.filter((n) => !!n.parentId)
|
||||||
|
const pastedNodes = [...parents, ...children]
|
||||||
|
|
||||||
|
// Deselect everything already on the canvas so only the paste is selected.
|
||||||
|
const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: [...state.nodes, ...newNodes],
|
nodes: [...existing, ...pastedNodes],
|
||||||
|
edges: [...state.edges, ...pastedEdges],
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: pastedNodes.map((n) => n.id),
|
||||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
future: [],
|
future: [],
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
@@ -497,13 +581,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|
||||||
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
|
toggleHideIp: () => set((s) => {
|
||||||
|
const hideIp = !s.hideIp
|
||||||
|
writeHideIp(hideIp)
|
||||||
|
return { hideIp }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setHideIp: (value) => {
|
||||||
|
writeHideIp(value)
|
||||||
|
set({ hideIp: value })
|
||||||
|
},
|
||||||
|
|
||||||
loadCanvas: (nodes, edges) => {
|
loadCanvas: (nodes, edges) => {
|
||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
const parents = nodes.filter((n) => !n.parentId)
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
const children = nodes.filter((n) => !!n.parentId)
|
const children = nodes.filter((n) => !!n.parentId)
|
||||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
|
// NOTE: clipboard is intentionally preserved here so nodes copied in one
|
||||||
|
// design can be pasted after switching to another design.
|
||||||
|
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
clearFitViewPending: () => set({ fitViewPending: false }),
|
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
|
||||||
|
describe('ipDisplay persistence', () => {
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
|
||||||
|
it('defaults to false when nothing is stored', () => {
|
||||||
|
expect(readHideIp()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips true', () => {
|
||||||
|
writeHideIp(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
expect(readHideIp()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips false', () => {
|
||||||
|
writeHideIp(true)
|
||||||
|
writeHideIp(false)
|
||||||
|
expect(readHideIp()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Persisted client-side preference for masking IP addresses on the canvas.
|
||||||
|
// Kept in localStorage (per-user UI preference, not canvas data) so it
|
||||||
|
// survives a page reload.
|
||||||
|
|
||||||
|
const KEY = 'homelable.hideIp'
|
||||||
|
|
||||||
|
export function readHideIp(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeHideIp(value: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(value))
|
||||||
|
} catch {
|
||||||
|
/* quota / SSR */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user