contex improvements

This commit is contained in:
2026-03-07 22:10:16 +01:00
parent 39719b1823
commit 848305fd87
9 changed files with 246 additions and 83 deletions

View File

@@ -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>