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:
@@ -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 },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user