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