feat: improve drag-and-drop feel in network diagram editor
Grip icons on draggable toolbar items, press effect on drag start, dashed border overlay with 'Drop to add' text when dragging over canvas. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ interface NetworkCanvasProps {
|
|||||||
onEdgeSelect: (edgeId: string | null) => void
|
onEdgeSelect: (edgeId: string | null) => void
|
||||||
onDrop: (event: React.DragEvent) => void
|
onDrop: (event: React.DragEvent) => void
|
||||||
onDragOver: (event: React.DragEvent) => void
|
onDragOver: (event: React.DragEvent) => void
|
||||||
|
onDragLeave?: (event: React.DragEvent) => void
|
||||||
|
isDragOver?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkCanvas({
|
export function NetworkCanvas({
|
||||||
@@ -36,6 +38,8 @@ export function NetworkCanvas({
|
|||||||
onEdgeSelect,
|
onEdgeSelect,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
isDragOver,
|
||||||
}: NetworkCanvasProps) {
|
}: NetworkCanvasProps) {
|
||||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||||
if (selectedNodes.length === 1) {
|
if (selectedNodes.length === 1) {
|
||||||
@@ -56,32 +60,41 @@ export function NetworkCanvas({
|
|||||||
}, [onNodeSelect, onEdgeSelect])
|
}, [onNodeSelect, onEdgeSelect])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlow
|
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
|
||||||
nodes={nodes}
|
<ReactFlow
|
||||||
edges={edges}
|
nodes={nodes}
|
||||||
onNodesChange={onNodesChange}
|
edges={edges}
|
||||||
onEdgesChange={onEdgesChange}
|
onNodesChange={onNodesChange}
|
||||||
onConnect={onConnect}
|
onEdgesChange={onEdgesChange}
|
||||||
onSelectionChange={handleSelectionChange}
|
onConnect={onConnect}
|
||||||
onPaneClick={handlePaneClick}
|
onSelectionChange={handleSelectionChange}
|
||||||
onDrop={onDrop}
|
onPaneClick={handlePaneClick}
|
||||||
onDragOver={onDragOver}
|
onDrop={onDrop}
|
||||||
nodeTypes={nodeTypes}
|
onDragOver={onDragOver}
|
||||||
edgeTypes={edgeTypes}
|
nodeTypes={nodeTypes}
|
||||||
defaultEdgeOptions={{ type: 'connection' }}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
defaultEdgeOptions={{ type: 'connection' }}
|
||||||
multiSelectionKeyCode="Shift"
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
fitView
|
multiSelectionKeyCode="Shift"
|
||||||
className="bg-page"
|
fitView
|
||||||
>
|
className="bg-page"
|
||||||
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
>
|
||||||
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
||||||
<MiniMap
|
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
||||||
nodeColor="var(--color-bg-elevated)"
|
<MiniMap
|
||||||
maskColor="rgba(0,0,0,0.5)"
|
nodeColor="var(--color-bg-elevated)"
|
||||||
className="!border-default !bg-card"
|
maskColor="rgba(0,0,0,0.5)"
|
||||||
position="bottom-right"
|
className="!border-default !bg-card"
|
||||||
/>
|
position="bottom-right"
|
||||||
</ReactFlow>
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
{isDragOver && (
|
||||||
|
<div className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/30">
|
||||||
|
<span className="rounded-md bg-card/80 px-3 py-1.5 text-sm text-muted-foreground">
|
||||||
|
Drop to add
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid } from 'lucide-react'
|
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid, GripVertical } from 'lucide-react'
|
||||||
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
|
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
|
||||||
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
|
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
|
||||||
import { deviceTypesApi } from '@/api'
|
import { deviceTypesApi } from '@/api'
|
||||||
@@ -107,8 +107,9 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
key={dt.id}
|
key={dt.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => handleDragStart(e, dt)}
|
onDragStart={e => handleDragStart(e, dt)}
|
||||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing"
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||||
<Icon size={14} style={{ color }} />
|
<Icon size={14} style={{ color }} />
|
||||||
<span>{dt.label}</span>
|
<span>{dt.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,8 +141,9 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
|
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
}}
|
}}
|
||||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing"
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||||
<LayoutGrid size={14} className="text-muted-foreground" />
|
<LayoutGrid size={14} className="text-muted-foreground" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function DiagramEditorInner() {
|
|||||||
|
|
||||||
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
|
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
|
||||||
const [loading, setLoading] = useState(!!id)
|
const [loading, setLoading] = useState(!!id)
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
||||||
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
||||||
@@ -196,10 +197,18 @@ function DiagramEditorInner() {
|
|||||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.dataTransfer.dropEffect = 'move'
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
setIsDragOver(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragLeave = useCallback((event: React.DragEvent) => {
|
||||||
|
const relatedTarget = event.relatedTarget as HTMLElement | null
|
||||||
|
if (relatedTarget && (event.currentTarget as HTMLElement).contains(relatedTarget)) return
|
||||||
|
setIsDragOver(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDrop = useCallback((event: React.DragEvent) => {
|
const onDrop = useCallback((event: React.DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
||||||
|
|
||||||
@@ -415,6 +424,8 @@ function DiagramEditorInner() {
|
|||||||
onEdgeSelect={setSelectedEdgeId}
|
onEdgeSelect={setSelectedEdgeId}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
isDragOver={isDragOver}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AIAssistPanel
|
<AIAssistPanel
|
||||||
|
|||||||
Reference in New Issue
Block a user