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 { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 {
|
interface DiagramHeaderProps {
|
||||||
name: string
|
name: string
|
||||||
@@ -18,6 +21,8 @@ interface DiagramHeaderProps {
|
|||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
canRedo: boolean
|
canRedo: boolean
|
||||||
|
interactionMode: InteractionMode
|
||||||
|
onModeChange: (mode: InteractionMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiagramHeader({
|
export function DiagramHeader({
|
||||||
@@ -36,6 +41,8 @@ export function DiagramHeader({
|
|||||||
onRedo,
|
onRedo,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
|
interactionMode,
|
||||||
|
onModeChange,
|
||||||
}: DiagramHeaderProps) {
|
}: DiagramHeaderProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -117,6 +124,36 @@ export function DiagramHeader({
|
|||||||
|
|
||||||
<div className="mx-2 h-5 w-px bg-border-default" />
|
<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 ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { nodeTypes } from './nodes/nodeTypes'
|
|||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
||||||
import type { DeviceNodeData } from './nodes/DeviceNode'
|
import type { DeviceNodeData } from './nodes/DeviceNode'
|
||||||
|
import type { InteractionMode } from './DiagramHeader'
|
||||||
|
|
||||||
interface NetworkCanvasProps {
|
interface NetworkCanvasProps {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
@@ -31,6 +32,7 @@ interface NetworkCanvasProps {
|
|||||||
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
||||||
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
||||||
onPaneClick?: () => void
|
onPaneClick?: () => void
|
||||||
|
interactionMode?: InteractionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkCanvas({
|
export function NetworkCanvas({
|
||||||
@@ -48,6 +50,7 @@ export function NetworkCanvas({
|
|||||||
onNodeContextMenu,
|
onNodeContextMenu,
|
||||||
onPaneContextMenu,
|
onPaneContextMenu,
|
||||||
onPaneClick: onPaneClickProp,
|
onPaneClick: onPaneClickProp,
|
||||||
|
interactionMode = 'select',
|
||||||
}: 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) {
|
||||||
@@ -93,10 +96,13 @@ export function NetworkCanvas({
|
|||||||
defaultEdgeOptions={{ type: 'connection' }}
|
defaultEdgeOptions={{ type: 'connection' }}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
multiSelectionKeyCode="Shift"
|
multiSelectionKeyCode="Shift"
|
||||||
|
panOnDrag={interactionMode === 'pan'}
|
||||||
|
selectionOnDrag={interactionMode === 'select'}
|
||||||
|
panActivationKeyCode="Space"
|
||||||
snapToGrid={true}
|
snapToGrid={true}
|
||||||
snapGrid={[20, 20]}
|
snapGrid={[20, 20]}
|
||||||
fitView
|
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} />
|
<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" />
|
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function useCanvasShortcuts({
|
|||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onNudge,
|
onNudge,
|
||||||
|
onSetMode,
|
||||||
}: {
|
}: {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
@@ -46,6 +47,7 @@ export function useCanvasShortcuts({
|
|||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
onNudge: (dx: number, dy: number) => void
|
onNudge: (dx: number, dy: number) => void
|
||||||
|
onSetMode: (mode: 'select' | 'pan') => void
|
||||||
}) {
|
}) {
|
||||||
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||||
const clipboardRef = useRef<ClipboardData | null>(null)
|
const clipboardRef = useRef<ClipboardData | null>(null)
|
||||||
@@ -242,6 +244,16 @@ export function useCanvasShortcuts({
|
|||||||
return
|
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') {
|
if (ctrl && e.key === 'c') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
copyNodes()
|
copyNodes()
|
||||||
@@ -268,7 +280,7 @@ export function useCanvasShortcuts({
|
|||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('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 {
|
return {
|
||||||
copyNodes,
|
copyNodes,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
|||||||
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
||||||
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
||||||
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
|
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 { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
||||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||||
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||||
@@ -69,6 +69,8 @@ function DiagramEditorInner() {
|
|||||||
const [loading, setLoading] = useState(!!id)
|
const [loading, setLoading] = useState(!!id)
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
||||||
@@ -154,6 +156,7 @@ function DiagramEditorInner() {
|
|||||||
onUndo: undo,
|
onUndo: undo,
|
||||||
onRedo: redo,
|
onRedo: redo,
|
||||||
onNudge,
|
onNudge,
|
||||||
|
onSetMode: setInteractionMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
||||||
@@ -675,6 +678,8 @@ function DiagramEditorInner() {
|
|||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
|
interactionMode={interactionMode}
|
||||||
|
onModeChange={setInteractionMode}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
||||||
@@ -695,6 +700,7 @@ function DiagramEditorInner() {
|
|||||||
onNodeContextMenu={handleNodeContextMenu}
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
onPaneContextMenu={handlePaneContextMenu}
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
onPaneClick={closeContextMenu}
|
onPaneClick={closeContextMenu}
|
||||||
|
interactionMode={interactionMode}
|
||||||
/>
|
/>
|
||||||
{nodes.length === 0 && !loading && (
|
{nodes.length === 0 && !loading && (
|
||||||
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
||||||
|
|||||||
Reference in New Issue
Block a user