contex improvements
This commit is contained in:
133
src/App.tsx
133
src/App.tsx
@@ -24,6 +24,7 @@ import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
@@ -31,9 +32,8 @@ import {
|
||||
ContextMenuGroup
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
|
||||
// Generate short unique node ids when missing
|
||||
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
|
||||
import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
|
||||
import { DEFAULT_NODE_STYLE, genId, getDefaultDataForType } from './lib/flowUtils'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
@@ -41,13 +41,6 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||
})
|
||||
|
||||
const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
|
||||
config: { width: 320, height: 320 },
|
||||
render: { width: 384, height: 320 },
|
||||
variable: { width: 224, height: 180 },
|
||||
function: { width: 288, height: 260 },
|
||||
}
|
||||
|
||||
const initialNodes: Node[] = [
|
||||
{
|
||||
id: 'config-1',
|
||||
@@ -77,7 +70,7 @@ export default function App() {
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
|
||||
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas' | 'node'; nodeId?: string; clientX: number; clientY: number }>(null)
|
||||
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
|
||||
|
||||
const nodeTypes = React.useMemo(
|
||||
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
|
||||
@@ -95,69 +88,92 @@ export default function App() {
|
||||
setRfInstance(instance)
|
||||
}, [])
|
||||
|
||||
const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
const nodeEl = target.closest('.react-flow__node')
|
||||
if (nodeEl) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
const target = ev.target as HTMLElement
|
||||
const nodeEl = target.closest('.react-flow__node') as HTMLElement | null
|
||||
const handleEl = target.closest('.react-flow__handle') as HTMLElement | null
|
||||
const clientX = ev.clientX
|
||||
const clientY = ev.clientY
|
||||
|
||||
lastClickRef.current = { clientX, clientY }
|
||||
|
||||
if (nodeEl && !handleEl) {
|
||||
const nodeId = nodeEl.dataset?.id || nodeEl.getAttribute('data-id') || (nodeEl.id && nodeEl.id.startsWith('reactflow__node-') ? nodeEl.id.replace('reactflow__node-', '') : undefined)
|
||||
setContextTarget({ type: 'node', nodeId, clientX, clientY })
|
||||
return
|
||||
}
|
||||
|
||||
setContextTarget({ type: 'canvas', clientX, clientY })
|
||||
}, [])
|
||||
|
||||
const getMenuPosition = React.useCallback(() => {
|
||||
if (!rfInstance) return null
|
||||
const click = contextTarget ?? lastClickRef.current
|
||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||
try {
|
||||
const screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project
|
||||
const p = screenToFlow.call(rfInstance, { x: clientX, y: clientY })
|
||||
return snapToGrid(p.x, p.y)
|
||||
} catch {
|
||||
return snapToGrid(clientX, clientY)
|
||||
}
|
||||
}, [rfInstance, contextTarget])
|
||||
|
||||
const createNode = React.useCallback(
|
||||
(type: string) => {
|
||||
if (!rfInstance) return
|
||||
const click = contextTarget ?? lastClickRef.current
|
||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||
let position: { x: number; y: number }
|
||||
try {
|
||||
// Convert screen (client) coordinates to flow coordinates — same position as where the context menu opened
|
||||
const screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project
|
||||
position = screenToFlow.call(rfInstance, { x: clientX, y: clientY })
|
||||
} catch {
|
||||
position = { x: clientX, y: clientY }
|
||||
}
|
||||
position = snapToGrid(position.x, position.y)
|
||||
|
||||
const id = genId()
|
||||
const position = getMenuPosition()
|
||||
if (position == null) return
|
||||
const newId = genId()
|
||||
const typeMap: Record<string, Node['type']> = {
|
||||
config: 'config',
|
||||
render: 'render',
|
||||
variable: 'variable',
|
||||
function: 'function',
|
||||
}
|
||||
const dataMap: Record<string, any> = {
|
||||
config: { plantuml: '@startuml\n\n@enduml\n', title: `config-${id}` },
|
||||
render: {},
|
||||
variable: { value: '', valueType: 'string' },
|
||||
function: { body: '// args[0], args[1], ...\nreturn args[0];' },
|
||||
}
|
||||
const nodeType = typeMap[type] ?? 'config'
|
||||
const dataMap = getDefaultDataForType(nodeType, newId)
|
||||
const newNode: Node = {
|
||||
id,
|
||||
id: newId,
|
||||
type: nodeType,
|
||||
position: { x: position.x, y: position.y },
|
||||
data: dataMap[type] ?? {},
|
||||
data: dataMap,
|
||||
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
|
||||
}
|
||||
setNodes((nds) => nds.concat(newNode))
|
||||
lastClickRef.current = null
|
||||
setContextTarget(null)
|
||||
},
|
||||
[rfInstance, setNodes, contextTarget]
|
||||
[getMenuPosition, setNodes]
|
||||
)
|
||||
|
||||
const VALID_NODE_TYPES = ['config', 'render', 'variable', 'function'] as const
|
||||
|
||||
const pasteNode = React.useCallback(async () => {
|
||||
const position = getMenuPosition()
|
||||
if (position == null) return
|
||||
try {
|
||||
const text = await navigator.clipboard?.readText()
|
||||
if (!text) return
|
||||
const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any }
|
||||
if (!raw || typeof raw.type !== 'string' || !VALID_NODE_TYPES.includes(raw.type as any)) return
|
||||
const newId = genId()
|
||||
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
|
||||
if (raw.type === 'config' && data.title != null) data.title = `config-${newId}`
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: raw.type as Node['type'],
|
||||
position: { x: position.x, y: position.y },
|
||||
data,
|
||||
style: DEFAULT_NODE_STYLE[raw.type] ?? DEFAULT_NODE_STYLE.config,
|
||||
}
|
||||
setNodes((nds) => nds.concat(newNode))
|
||||
lastClickRef.current = null
|
||||
setContextTarget(null)
|
||||
} catch {
|
||||
// Invalid clipboard or not a copied node — do nothing
|
||||
}
|
||||
}, [getMenuPosition, setNodes])
|
||||
|
||||
const deleteNode = React.useCallback((id: string | undefined) => {
|
||||
if (!id) return
|
||||
setNodes((nds) => nds.filter((n) => n.id !== id))
|
||||
@@ -166,7 +182,13 @@ export default function App() {
|
||||
}, [setNodes, setEdges])
|
||||
|
||||
return (
|
||||
<div className="reactflow-wrapper" ref={wrapperRef} onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }}>
|
||||
<div
|
||||
className="reactflow-wrapper"
|
||||
ref={wrapperRef}
|
||||
onContextMenuCapture={onContextMenuCapture}
|
||||
onContextMenu={onCanvasContextMenu}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -206,13 +228,8 @@ export default function App() {
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-48">
|
||||
{contextTarget?.type === 'node' ? (
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem onSelect={() => deleteNode(contextTarget.nodeId)}>Delete</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
) : (
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
<ContextMenuGroup>
|
||||
@@ -235,8 +252,12 @@ export default function App() {
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuGroup>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => pasteNode()}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</FlowContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user