feat(network): add pointer/hand mode toggle to diagram toolbar

- Header shows MousePointer2 (select) and Hand (pan) toggle buttons
- Select mode: drag on canvas draws a selection box (selectionOnDrag)
- Pan mode: drag on canvas pans the viewport (panOnDrag)
- Space held in either mode temporarily switches to pan (panActivationKeyCode)
- Keyboard shortcuts: V = select mode, H = pan mode
- Cursor changes to grab/grabbing in pan mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-14 00:38:51 +00:00
parent 4a12c9b37d
commit 684fb07e47
4 changed files with 65 additions and 4 deletions

View File

@@ -1,6 +1,9 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Save, Download, FileJson, Image, FileText, Undo2, Redo2 } from 'lucide-react'
import { ChevronLeft, Save, Download, FileJson, Image, FileText, Undo2, Redo2, MousePointer2, Hand } from 'lucide-react'
import { cn } from '@/lib/utils'
export type InteractionMode = 'select' | 'pan'
interface DiagramHeaderProps {
name: string
@@ -18,6 +21,8 @@ interface DiagramHeaderProps {
onRedo: () => void
canUndo: boolean
canRedo: boolean
interactionMode: InteractionMode
onModeChange: (mode: InteractionMode) => void
}
export function DiagramHeader({
@@ -36,6 +41,8 @@ export function DiagramHeader({
onRedo,
canUndo,
canRedo,
interactionMode,
onModeChange,
}: DiagramHeaderProps) {
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
@@ -117,6 +124,36 @@ export function DiagramHeader({
<div className="mx-2 h-5 w-px bg-border-default" />
{/* Interaction mode toggle */}
<div className="flex items-center rounded border border-default overflow-hidden">
<button
onClick={() => onModeChange('select')}
title="Select (V)"
className={cn(
'p-1.5 transition-colors',
interactionMode === 'select'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<MousePointer2 size={15} />
</button>
<button
onClick={() => onModeChange('pan')}
title="Pan (H)"
className={cn(
'p-1.5 transition-colors',
interactionMode === 'pan'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<Hand size={15} />
</button>
</div>
<div className="mx-2 h-5 w-px bg-border-default" />
{editing ? (
<input
ref={inputRef}

View File

@@ -15,6 +15,7 @@ import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
import type { DeviceNodeData } from './nodes/DeviceNode'
import type { InteractionMode } from './DiagramHeader'
interface NetworkCanvasProps {
nodes: Node[]
@@ -31,6 +32,7 @@ interface NetworkCanvasProps {
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
onPaneClick?: () => void
interactionMode?: InteractionMode
}
export function NetworkCanvas({
@@ -48,6 +50,7 @@ export function NetworkCanvas({
onNodeContextMenu,
onPaneContextMenu,
onPaneClick: onPaneClickProp,
interactionMode = 'select',
}: NetworkCanvasProps) {
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length === 1) {
@@ -93,10 +96,13 @@ export function NetworkCanvas({
defaultEdgeOptions={{ type: 'connection' }}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode="Shift"
panOnDrag={interactionMode === 'pan'}
selectionOnDrag={interactionMode === 'select'}
panActivationKeyCode="Space"
snapToGrid={true}
snapGrid={[20, 20]}
fitView
className="bg-page"
className={interactionMode === 'pan' ? 'bg-page cursor-grab active:cursor-grabbing' : '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" />

View File

@@ -36,6 +36,7 @@ export function useCanvasShortcuts({
onUndo,
onRedo,
onNudge,
onSetMode,
}: {
nodes: Node[]
edges: Edge[]
@@ -46,6 +47,7 @@ export function useCanvasShortcuts({
onUndo: () => void
onRedo: () => void
onNudge: (dx: number, dy: number) => void
onSetMode: (mode: 'select' | 'pan') => void
}) {
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
const clipboardRef = useRef<ClipboardData | null>(null)
@@ -242,6 +244,16 @@ export function useCanvasShortcuts({
return
}
// Mode shortcuts: V = select, H = pan
if (!ctrl && e.key === 'v') {
onSetMode('select')
return
}
if (!ctrl && e.key === 'h') {
onSetMode('pan')
return
}
if (ctrl && e.key === 'c') {
e.preventDefault()
copyNodes()
@@ -268,7 +280,7 @@ export function useCanvasShortcuts({
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge])
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode])
return {
copyNodes,

View File

@@ -18,7 +18,7 @@ import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
import { DiagramHeader } from '@/components/network/DiagramHeader'
import { DiagramHeader, type InteractionMode } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
@@ -69,6 +69,8 @@ function DiagramEditorInner() {
const [loading, setLoading] = useState(!!id)
const [isDragOver, setIsDragOver] = useState(false)
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
const canvasRef = useRef<HTMLDivElement | null>(null)
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
@@ -154,6 +156,7 @@ function DiagramEditorInner() {
onUndo: undo,
onRedo: redo,
onNudge,
onSetMode: setInteractionMode,
})
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
@@ -675,6 +678,8 @@ function DiagramEditorInner() {
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
interactionMode={interactionMode}
onModeChange={setInteractionMode}
/>
<div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
@@ -695,6 +700,7 @@ function DiagramEditorInner() {
onNodeContextMenu={handleNodeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
onPaneClick={closeContextMenu}
interactionMode={interactionMode}
/>
{nodes.length === 0 && !loading && (
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />