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 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-13 02:29:15 +00:00
parent 2a6178e246
commit dd1a13d713
4 changed files with 108 additions and 20 deletions

View File

@@ -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) => (
<button
key={action.label}
onClick={() => {
action.onClick()
onClose()
}}
disabled={action.disabled}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated',
action.disabled && 'opacity-40 pointer-events-none',
<div key={action.label}>
{action.dividerBefore && (
<div className="my-1 border-t border-default" />
)}
>
<action.icon size={14} />
<span>{action.label}</span>
<span className="ml-auto text-[10px] text-muted-foreground">{action.shortcut}</span>
</button>
<button
onClick={() => {
action.onClick()
onClose()
}}
disabled={action.disabled}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated',
action.disabled && 'opacity-40 pointer-events-none',
)}
>
<action.icon size={14} />
<span>{action.label}</span>
<span className="ml-auto text-[10px] text-muted-foreground">{action.shortcut}</span>
</button>
</div>
))}
</div>
)
@@ -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 },
]
}

View File

@@ -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,
}
}

View File

@@ -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<DeviceNodeData>) => void
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => 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({
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
</div>
{/* Layering */}
<div className="flex flex-col gap-1">
<FieldLabel>Layer</FieldLabel>
<div className="flex gap-1.5">
<button
onClick={() => onBringToFront(selectedNode!.id)}
title="Bring to Front ]"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
>
<BringToFront size={12} />
Bring Front
</button>
<button
onClick={() => onSendToBack(selectedNode!.id)}
title="Send to Back ["
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
>
<SendToBack size={12} />
Send Back
</button>
</div>
</div>
{/* Status badge grid */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Status</FieldLabel>

View File

@@ -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)