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 { 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'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface MenuAction {
|
interface MenuAction {
|
||||||
@@ -8,6 +8,7 @@ interface MenuAction {
|
|||||||
shortcut: string
|
shortcut: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
dividerBefore?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
@@ -21,8 +22,10 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
|||||||
|
|
||||||
const clampedPosition = { ...position }
|
const clampedPosition = { ...position }
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
const itemCount = actions.length
|
||||||
|
const dividerCount = actions.filter(a => a.dividerBefore).length
|
||||||
const menuWidth = 192
|
const menuWidth = 192
|
||||||
const menuHeight = actions.length * 36 + 8
|
const menuHeight = itemCount * 36 + dividerCount * 9 + 8
|
||||||
if (clampedPosition.x + menuWidth > window.innerWidth) {
|
if (clampedPosition.x + menuWidth > window.innerWidth) {
|
||||||
clampedPosition.x = window.innerWidth - menuWidth - 8
|
clampedPosition.x = window.innerWidth - menuWidth - 8
|
||||||
}
|
}
|
||||||
@@ -59,22 +62,26 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
|||||||
style={{ left: clampedPosition.x, top: clampedPosition.y }}
|
style={{ left: clampedPosition.x, top: clampedPosition.y }}
|
||||||
>
|
>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<button
|
<div key={action.label}>
|
||||||
key={action.label}
|
{action.dividerBefore && (
|
||||||
onClick={() => {
|
<div className="my-1 border-t border-default" />
|
||||||
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',
|
|
||||||
)}
|
)}
|
||||||
>
|
<button
|
||||||
<action.icon size={14} />
|
onClick={() => {
|
||||||
<span>{action.label}</span>
|
action.onClick()
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground">{action.shortcut}</span>
|
onClose()
|
||||||
</button>
|
}}
|
||||||
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -83,12 +90,16 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
|||||||
export function getNodeMenuActions(handlers: {
|
export function getNodeMenuActions(handlers: {
|
||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
|
onBringToFront: () => void
|
||||||
|
onSendToBack: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}): MenuAction[] {
|
}): MenuAction[] {
|
||||||
return [
|
return [
|
||||||
{ label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy },
|
{ label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy },
|
||||||
{ label: 'Duplicate', icon: CopyPlus, shortcut: 'Ctrl+D', onClick: handlers.onDuplicate },
|
{ 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)
|
setIsDirty(true)
|
||||||
}, [getSelectedNodes, setNodes, setEdges, setIsDirty])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (isInputFocused()) return
|
if (isInputFocused()) return
|
||||||
@@ -204,12 +226,18 @@ export function useCanvasShortcuts({
|
|||||||
} else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
} else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
fitView({ padding: 0.2 })
|
fitView({ padding: 0.2 })
|
||||||
|
} else if (e.key === ']' && !ctrl) {
|
||||||
|
e.preventDefault()
|
||||||
|
bringSelectedToFront()
|
||||||
|
} else if (e.key === '[' && !ctrl) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendSelectedToBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView])
|
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copyNodes,
|
copyNodes,
|
||||||
@@ -217,6 +245,8 @@ export function useCanvasShortcuts({
|
|||||||
duplicateNodes,
|
duplicateNodes,
|
||||||
selectAll,
|
selectAll,
|
||||||
deleteSelected,
|
deleteSelected,
|
||||||
|
bringSelectedToFront,
|
||||||
|
sendSelectedToBack,
|
||||||
hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0,
|
hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react'
|
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 { cn } from '@/lib/utils'
|
||||||
import type { DeviceProperties, DiagramEdge } from '@/types'
|
import type { DeviceProperties, DiagramEdge } from '@/types'
|
||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
@@ -11,6 +11,8 @@ interface PropertiesPanelProps {
|
|||||||
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
|
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
|
||||||
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
|
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
|
||||||
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
|
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
|
||||||
|
onBringToFront: (nodeId: string) => void
|
||||||
|
onSendToBack: (nodeId: string) => void
|
||||||
onDeleteNode: (nodeId: string) => void
|
onDeleteNode: (nodeId: string) => void
|
||||||
onDeleteEdge: (edgeId: string) => void
|
onDeleteEdge: (edgeId: string) => void
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,8 @@ export function PropertiesPanel({
|
|||||||
onNodeUpdate,
|
onNodeUpdate,
|
||||||
onEdgeUpdate,
|
onEdgeUpdate,
|
||||||
onEdgeTypeChange,
|
onEdgeTypeChange,
|
||||||
|
onBringToFront,
|
||||||
|
onSendToBack,
|
||||||
onDeleteNode,
|
onDeleteNode,
|
||||||
onDeleteEdge,
|
onDeleteEdge,
|
||||||
}: PropertiesPanelProps) {
|
}: PropertiesPanelProps) {
|
||||||
@@ -268,6 +272,29 @@ export function PropertiesPanel({
|
|||||||
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
||||||
</div>
|
</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 */}
|
{/* Status badge grid */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<FieldLabel>Status</FieldLabel>
|
<FieldLabel>Status</FieldLabel>
|
||||||
|
|||||||
@@ -400,6 +400,22 @@ function DiagramEditorInner() {
|
|||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setEdges])
|
}, [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 handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
|
||||||
const newNodes: Node[] = result.nodes.map(n => ({
|
const newNodes: Node[] = result.nodes.map(n => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@@ -574,6 +590,8 @@ function DiagramEditorInner() {
|
|||||||
onNodeUpdate={handleNodeUpdate}
|
onNodeUpdate={handleNodeUpdate}
|
||||||
onEdgeUpdate={handleEdgeUpdate}
|
onEdgeUpdate={handleEdgeUpdate}
|
||||||
onEdgeTypeChange={handleEdgeTypeChange}
|
onEdgeTypeChange={handleEdgeTypeChange}
|
||||||
|
onBringToFront={handleBringToFront}
|
||||||
|
onSendToBack={handleSendToBack}
|
||||||
onDeleteNode={handleDeleteNode}
|
onDeleteNode={handleDeleteNode}
|
||||||
onDeleteEdge={handleDeleteEdge}
|
onDeleteEdge={handleDeleteEdge}
|
||||||
/>
|
/>
|
||||||
@@ -586,6 +604,8 @@ function DiagramEditorInner() {
|
|||||||
? getNodeMenuActions({
|
? getNodeMenuActions({
|
||||||
onCopy: copyNodes,
|
onCopy: copyNodes,
|
||||||
onDuplicate: duplicateNodes,
|
onDuplicate: duplicateNodes,
|
||||||
|
onBringToFront: () => { if (contextMenu.nodeId) handleBringToFront(contextMenu.nodeId) },
|
||||||
|
onSendToBack: () => { if (contextMenu.nodeId) handleSendToBack(contextMenu.nodeId) },
|
||||||
onDelete: () => {
|
onDelete: () => {
|
||||||
const nodeId = contextMenu.nodeId
|
const nodeId = contextMenu.nodeId
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user