From dd1a13d713c7ed9b05f4053f2f6d8fe40246a438 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 02:29:15 +0000 Subject: [PATCH] feat(network-maps): bring to front / send to back layering for nodes Three entry points for z-index control: - Right-click context menu: Bring to Front / Send to Back with ] / [ shortcuts, separated by dividers from copy/delete groups - Properties panel: Layer row with Bring Front + Send Back buttons, tooltip shows keyboard shortcut - Keyboard: ] brings selected node(s) to front, [ sends to back (skips when input focused) Context menu also gains divider support (dividerBefore flag) for visual grouping. Layering handlers use max/min zIndex across all nodes so repeated presses always stack correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/ContextMenu.tsx | 47 ++++++++++++------- .../network/hooks/useCanvasShortcuts.ts | 32 ++++++++++++- .../network/panels/PropertiesPanel.tsx | 29 +++++++++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 20 ++++++++ 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/network/ContextMenu.tsx b/frontend/src/components/network/ContextMenu.tsx index 5597ba16..6caae3a3 100644 --- a/frontend/src/components/network/ContextMenu.tsx +++ b/frontend/src/components/network/ContextMenu.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2 } from 'lucide-react' +import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack } from 'lucide-react' import { cn } from '@/lib/utils' interface MenuAction { @@ -8,6 +8,7 @@ interface MenuAction { shortcut: string onClick: () => void disabled?: boolean + dividerBefore?: boolean } interface ContextMenuProps { @@ -21,8 +22,10 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) { const clampedPosition = { ...position } if (typeof window !== 'undefined') { + const itemCount = actions.length + const dividerCount = actions.filter(a => a.dividerBefore).length const menuWidth = 192 - const menuHeight = actions.length * 36 + 8 + const menuHeight = itemCount * 36 + dividerCount * 9 + 8 if (clampedPosition.x + menuWidth > window.innerWidth) { clampedPosition.x = window.innerWidth - menuWidth - 8 } @@ -59,22 +62,26 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) { style={{ left: clampedPosition.x, top: clampedPosition.y }} > {actions.map((action) => ( - + + ))} ) @@ -83,12 +90,16 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) { export function getNodeMenuActions(handlers: { onCopy: () => void onDuplicate: () => void + onBringToFront: () => void + onSendToBack: () => void onDelete: () => void }): MenuAction[] { return [ { label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy }, { label: 'Duplicate', icon: CopyPlus, shortcut: 'Ctrl+D', onClick: handlers.onDuplicate }, - { label: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete }, + { label: 'Bring to Front', icon: BringToFront, shortcut: ']', onClick: handlers.onBringToFront, dividerBefore: true }, + { label: 'Send to Back', icon: SendToBack, shortcut: '[', onClick: handlers.onSendToBack }, + { label: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete, dividerBefore: true }, ] } diff --git a/frontend/src/components/network/hooks/useCanvasShortcuts.ts b/frontend/src/components/network/hooks/useCanvasShortcuts.ts index 80ccb130..05d3eb48 100644 --- a/frontend/src/components/network/hooks/useCanvasShortcuts.ts +++ b/frontend/src/components/network/hooks/useCanvasShortcuts.ts @@ -183,6 +183,28 @@ export function useCanvasShortcuts({ setIsDirty(true) }, [getSelectedNodes, setNodes, setEdges, setIsDirty]) + const bringSelectedToFront = useCallback(() => { + const selected = getSelectedNodes() + if (!selected.length) return + const selectedIds = new Set(selected.map(n => n.id)) + setNodes(nds => { + const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0)) + return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: maxZ + 1 } : n) + }) + setIsDirty(true) + }, [getSelectedNodes, setNodes, setIsDirty]) + + const sendSelectedToBack = useCallback(() => { + const selected = getSelectedNodes() + if (!selected.length) return + const selectedIds = new Set(selected.map(n => n.id)) + setNodes(nds => { + const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0)) + return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: minZ - 1 } : n) + }) + setIsDirty(true) + }, [getSelectedNodes, setNodes, setIsDirty]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (isInputFocused()) return @@ -204,12 +226,18 @@ export function useCanvasShortcuts({ } else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) { e.preventDefault() fitView({ padding: 0.2 }) + } else if (e.key === ']' && !ctrl) { + e.preventDefault() + bringSelectedToFront() + } else if (e.key === '[' && !ctrl) { + e.preventDefault() + sendSelectedToBack() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView]) + }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack]) return { copyNodes, @@ -217,6 +245,8 @@ export function useCanvasShortcuts({ duplicateNodes, selectAll, deleteSelected, + bringSelectedToFront, + sendSelectedToBack, hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0, } } diff --git a/frontend/src/components/network/panels/PropertiesPanel.tsx b/frontend/src/components/network/panels/PropertiesPanel.tsx index f738157d..bdc72c0a 100644 --- a/frontend/src/components/network/panels/PropertiesPanel.tsx +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useState, useEffect } from 'react' -import { Trash2, Minus, Spline, GitBranch } from 'lucide-react' +import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'lucide-react' import { cn } from '@/lib/utils' import type { DeviceProperties, DiagramEdge } from '@/types' import type { Node, Edge } from '@xyflow/react' @@ -11,6 +11,8 @@ interface PropertiesPanelProps { onNodeUpdate: (nodeId: string, data: Partial) => void onEdgeUpdate: (edgeId: string, data: Partial) => void onEdgeTypeChange: (edgeId: string, edgeType: string) => void + onBringToFront: (nodeId: string) => void + onSendToBack: (nodeId: string) => void onDeleteNode: (nodeId: string) => void onDeleteEdge: (edgeId: string) => void } @@ -72,6 +74,8 @@ export function PropertiesPanel({ onNodeUpdate, onEdgeUpdate, onEdgeTypeChange, + onBringToFront, + onSendToBack, onDeleteNode, onDeleteEdge, }: PropertiesPanelProps) { @@ -268,6 +272,29 @@ export function PropertiesPanel({ + {/* Layering */} +
+ Layer +
+ + +
+
+ {/* Status badge grid */}
Status diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index f06a646b..9ab258bc 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -400,6 +400,22 @@ function DiagramEditorInner() { setIsDirty(true) }, [setEdges]) + const handleBringToFront = useCallback((nodeId: string) => { + setNodes(nds => { + const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0)) + return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) + }) + setIsDirty(true) + }, [setNodes]) + + const handleSendToBack = useCallback((nodeId: string) => { + setNodes(nds => { + const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0)) + return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n) + }) + setIsDirty(true) + }, [setNodes]) + const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => { const newNodes: Node[] = result.nodes.map(n => ({ id: n.id, @@ -574,6 +590,8 @@ function DiagramEditorInner() { onNodeUpdate={handleNodeUpdate} onEdgeUpdate={handleEdgeUpdate} onEdgeTypeChange={handleEdgeTypeChange} + onBringToFront={handleBringToFront} + onSendToBack={handleSendToBack} onDeleteNode={handleDeleteNode} onDeleteEdge={handleDeleteEdge} /> @@ -586,6 +604,8 @@ function DiagramEditorInner() { ? getNodeMenuActions({ onCopy: copyNodes, onDuplicate: duplicateNodes, + onBringToFront: () => { if (contextMenu.nodeId) handleBringToFront(contextMenu.nodeId) }, + onSendToBack: () => { if (contextMenu.nodeId) handleSendToBack(contextMenu.nodeId) }, onDelete: () => { const nodeId = contextMenu.nodeId setContextMenu(null)