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'
|
||||
|
||||
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 { isAuthenticated } = useAuthStore()
|
||||
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
|
||||
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(() => {
|
||||
@@ -232,8 +228,8 @@ export default function App() {
|
||||
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 }
|
||||
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||
// nodes under the cursor / viewport center.
|
||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -40,8 +40,34 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onNodesChange, onEdgesChange,
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
copySelectedNodes, pasteNodes,
|
||||
} = 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)
|
||||
useEffect(() => {
|
||||
@@ -100,7 +126,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||
|
||||
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
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type AlignmentSettings,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
subscribeAlignmentSettings,
|
||||
} from '@/utils/alignmentSettings'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -19,9 +22,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const [interval, setIntervalValue] = useState(60)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open || STANDALONE) return
|
||||
settingsApi.get()
|
||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||
.catch(() => {/* use default */})
|
||||
@@ -36,6 +41,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
}
|
||||
|
||||
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)
|
||||
try {
|
||||
await settingsApi.save({ interval_seconds: interval })
|
||||
@@ -57,6 +68,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Status checker */}
|
||||
{!STANDALONE && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||
<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…)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
@@ -90,6 +103,17 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
/>
|
||||
</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'}>
|
||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('@/api/client', () => ({
|
||||
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
@@ -64,6 +65,17 @@ describe('SettingsModal', () => {
|
||||
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 () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SettingsModal open onClose={onClose} />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
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 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} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!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
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
@@ -268,14 +261,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
badge={hasUnsavedChanges}
|
||||
accent
|
||||
/>
|
||||
{!STANDALONE && (
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
collapsed={collapsed}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
)}
|
||||
{!STANDALONE && (
|
||||
<SidebarItem
|
||||
icon={LogOut}
|
||||
|
||||
@@ -46,15 +46,12 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
||||
data: { label: id, type, status, services: [] },
|
||||
})
|
||||
|
||||
const mockToggleHideIp = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
hasUnsavedChanges: false,
|
||||
hideIp: false,
|
||||
toggleHideIp: mockToggleHideIp,
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
...overrides,
|
||||
@@ -191,16 +188,10 @@ describe('Sidebar', () => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls toggleHideIp when Hide IPs is clicked', () => {
|
||||
it('calls onOpenSettings when Settings is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Hide IPs'))
|
||||
expect(mockToggleHideIp).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Show IPs label when hideIp is true', () => {
|
||||
mockStore({ hideIp: true })
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
expect(screen.getByText('Show IPs')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('canvasStore', () => {
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -690,29 +690,136 @@ describe('canvasStore', () => {
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
const { clipboard } = useCanvasStore.getState()
|
||||
expect(clipboard).toHaveLength(1)
|
||||
expect(clipboard[0].id).toBe('a')
|
||||
expect(clipboard.nodes).toHaveLength(1)
|
||||
expect(clipboard.nodes[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] })
|
||||
it('copySelectedNodes captures edges whose endpoints are both selected', () => {
|
||||
useCanvasStore.setState({
|
||||
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()
|
||||
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)
|
||||
expect(nodes).toHaveLength(1)
|
||||
const pasted = nodes[0]
|
||||
expect(pasted.id).not.toBe('src')
|
||||
expect(pasted.position).toEqual({ x: 150, y: 150 })
|
||||
expect(pasted.selected).toBe(true)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
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) ---
|
||||
|
||||
it('addNode preserves explicit width and height', () => {
|
||||
@@ -867,7 +974,7 @@ describe('canvasStore — custom style apply', () => {
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||
|
||||
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 {
|
||||
nodes: Node<NodeData>[]
|
||||
@@ -31,10 +36,12 @@ interface CanvasState {
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
|
||||
// Clipboard
|
||||
clipboard: Node<NodeData>[]
|
||||
// Clipboard — survives design switches so nodes can be pasted into another design
|
||||
clipboard: Clipboard
|
||||
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
|
||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||
@@ -63,6 +70,7 @@ interface CanvasState {
|
||||
notifyScanDeviceFound: () => void
|
||||
hideIp: boolean
|
||||
toggleHideIp: () => void
|
||||
setHideIp: (value: boolean) => void
|
||||
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||
@@ -76,13 +84,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
editingTextId: null,
|
||||
hideIp: false,
|
||||
hideIp: readHideIp(),
|
||||
scanEventTs: 0,
|
||||
fitViewPending: false,
|
||||
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
|
||||
snapshotHistory: () =>
|
||||
set((state) => ({
|
||||
@@ -117,24 +125,100 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}),
|
||||
|
||||
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: generateUUID(),
|
||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||
selected: false,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
data: { ...n.data, parent_id: undefined },
|
||||
}))
|
||||
// Start from explicitly selected nodes, then pull in all descendants so a
|
||||
// copied group / container brings its children along.
|
||||
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 {
|
||||
nodes: [...state.nodes, ...newNodes],
|
||||
...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(),
|
||||
source: idMap.get(e.source)!,
|
||||
target: idMap.get(e.target)!,
|
||||
selected: false,
|
||||
}))
|
||||
|
||||
// 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 {
|
||||
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 }],
|
||||
future: [],
|
||||
hasUnsavedChanges: true,
|
||||
@@ -497,13 +581,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
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) => {
|
||||
// React Flow requires parents before children in the array
|
||||
const parents = 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 }),
|
||||
|
||||
@@ -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