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, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub, ContextMenuSub,
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
@@ -31,9 +32,8 @@ import {
ContextMenuGroup ContextMenuGroup
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react' import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
// Generate short unique node ids when missing import { DEFAULT_NODE_STYLE, genId, getDefaultDataForType } from './lib/flowUtils'
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ 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], 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[] = [ const initialNodes: Node[] = [
{ {
id: 'config-1', id: 'config-1',
@@ -77,7 +70,7 @@ export default function App() {
const wrapperRef = React.useRef<HTMLDivElement | null>(null) const wrapperRef = React.useRef<HTMLDivElement | null>(null)
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | 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( const nodeTypes = React.useMemo(
() => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }), () => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }),
@@ -95,69 +88,92 @@ export default function App() {
setRfInstance(instance) 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) => { const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => {
ev.preventDefault() 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 clientX = ev.clientX
const clientY = ev.clientY const clientY = ev.clientY
lastClickRef.current = { clientX, 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 }) 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( const createNode = React.useCallback(
(type: string) => { (type: string) => {
if (!rfInstance) return const position = getMenuPosition()
const click = contextTarget ?? lastClickRef.current if (position == null) return
const clientX = click?.clientX ?? window.innerWidth / 2 const newId = genId()
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 typeMap: Record<string, Node['type']> = { const typeMap: Record<string, Node['type']> = {
config: 'config', config: 'config',
render: 'render', render: 'render',
variable: 'variable', variable: 'variable',
function: 'function', 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 nodeType = typeMap[type] ?? 'config'
const dataMap = getDefaultDataForType(nodeType, newId)
const newNode: Node = { const newNode: Node = {
id, id: newId,
type: nodeType, type: nodeType,
position: { x: position.x, y: position.y }, position: { x: position.x, y: position.y },
data: dataMap[type] ?? {}, data: dataMap,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config, style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
} }
setNodes((nds) => nds.concat(newNode)) setNodes((nds) => nds.concat(newNode))
lastClickRef.current = null lastClickRef.current = null
setContextTarget(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) => { const deleteNode = React.useCallback((id: string | undefined) => {
if (!id) return if (!id) return
setNodes((nds) => nds.filter((n) => n.id !== id)) setNodes((nds) => nds.filter((n) => n.id !== id))
@@ -166,7 +182,13 @@ export default function App() {
}, [setNodes, setEdges]) }, [setNodes, setEdges])
return ( 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 <Button
variant="outline" variant="outline"
size="icon" size="icon"
@@ -206,13 +228,8 @@ export default function App() {
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="w-48"> <ContextMenuContent className="w-48">
{contextTarget?.type === 'node' ? ( <ContextMenuGroup>
<ContextMenuGroup> <ContextMenuSub>
<ContextMenuItem onSelect={() => deleteNode(contextTarget.nodeId)}>Delete</ContextMenuItem>
</ContextMenuGroup>
) : (
<ContextMenuGroup>
<ContextMenuSub>
<ContextMenuSubTrigger>Create node</ContextMenuSubTrigger> <ContextMenuSubTrigger>Create node</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44"> <ContextMenuSubContent className="w-44">
<ContextMenuGroup> <ContextMenuGroup>
@@ -235,8 +252,12 @@ export default function App() {
</ContextMenuGroup> </ContextMenuGroup>
</ContextMenuSubContent> </ContextMenuSubContent>
</ContextMenuSub> </ContextMenuSub>
</ContextMenuGroup> <ContextMenuSeparator />
)} <ContextMenuItem onSelect={() => pasteNode()}>
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</FlowContext.Provider> </FlowContext.Provider>

View File

@@ -32,13 +32,13 @@ export function BaseNode({
// and would be clipped. The inner content wrapper has overflow-hidden for scrolling. // and would be clipped. The inner content wrapper has overflow-hidden for scrolling.
const appliedStyle = hasSize const appliedStyle = hasSize
? { ? {
...style, ...style,
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
display: "flex" as const, display: "flex" as const,
flexDirection: "column" as const, flexDirection: "column" as const,
contain: "layout" as const, contain: "layout" as const,
} }
: style : style
? { ...style, display: "flex", flexDirection: "column" as const } ? { ...style, display: "flex", flexDirection: "column" as const }
: undefined; : undefined;
@@ -184,7 +184,7 @@ export function BaseNodeContent({
return ( return (
<div <div
data-slot="base-node-content" data-slot="base-node-content"
className={cn("min-h-0 flex-1 flex flex-col gap-y-2 overflow-auto pt-1", className)} className={cn("min-h-0 flex-1 flex flex-col overflow-auto pt-1", className)}
{...props} {...props}
/> />
); );

View File

@@ -13,18 +13,15 @@ import {
BaseNodeFooterText, BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { GitBranchPlus, ScrollText } from 'lucide-react' import { ScrollText } from 'lucide-react'
import { import {
Menubar,
MenubarContent,
MenubarItem, MenubarItem,
MenubarMenu,
MenubarSub, MenubarSub,
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
MenubarTrigger,
} from '../ui/menubar' } from '../ui/menubar'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeMenubar } from './NodeMenubar'
type Props = { type Props = {
id: string id: string
@@ -159,14 +156,13 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={`${id}.puml`} /> <BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={`${id}.puml`} />
<BaseNodeContent> <BaseNodeContent>
{hasDependencies && ( <div className="shrink-0 w-full">
<div className="shrink-0 w-full"> <NodeMenubar
<Menubar className="h-auto bg-none p-1 border-none shadow-none"> nodeId={id}
<MenubarMenu> nodeType="config"
<MenubarTrigger className="px-1.5 py-0 text-xs"> editInputsContent={
<GitBranchPlus className="size-3.5" /> hasDependencies ? (
</MenubarTrigger> <>
<MenubarContent className="min-w-[12rem]">
{connectedConfigNodes.map((n: any) => ( {connectedConfigNodes.map((n: any) => (
<MenubarSub key={`config-${n.id}`}> <MenubarSub key={`config-${n.id}`}>
<MenubarSubTrigger className="text-xs"> <MenubarSubTrigger className="text-xs">
@@ -224,11 +220,11 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </MenubarSub>
))} ))}
</MenubarContent> </>
</MenubarMenu> ) : undefined
</Menubar> }
</div> />
)} </div>
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden rounded border border-input"> <div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden rounded border border-input">
<CodeMirror <CodeMirror

View File

@@ -12,6 +12,7 @@ import {
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeMenubar } from './NodeMenubar'
import { Code2 } from 'lucide-react' import { Code2 } from 'lucide-react'
type Props = { type Props = {
@@ -66,6 +67,9 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={id} /> <BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={id} />
<BaseNodeContent> <BaseNodeContent>
<div className="shrink-0 w-full mb-1">
<NodeMenubar nodeId={id} nodeType="function" />
</div>
<p className="shrink-0 text-[10px] text-muted-foreground mb-1"> <p className="shrink-0 text-[10px] text-muted-foreground mb-1">
Call in config: <code className="rounded bg-muted px-0.5">$&#123;{id}(var1, var2)&#125;</code> Call in config: <code className="rounded bg-muted px-0.5">$&#123;{id}(var1, var2)&#125;</code>
</p> </p>

View File

@@ -0,0 +1,112 @@
import React, { useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext'
import { DEFAULT_NODE_STYLE, genId, getDefaultDataForType } from '../../lib/flowUtils'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from '../ui/menubar'
const DUPLICATE_OFFSET = { x: 30, y: 30 }
type NodeType = 'config' | 'render' | 'variable' | 'function'
type Props = {
nodeId: string
nodeType: NodeType
/** Content for Edit → Inputs (config and function nodes only) */
editInputsContent?: React.ReactNode
}
export function NodeMenubar({ nodeId, nodeType, editInputsContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const edges = ctx?.edges ?? []
const node = nodes.find((n: any) => n.id === nodeId)
const hasEdit = nodeType === 'config' || nodeType === 'function'
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
const onDuplicate = useCallback(() => {
if (!setNodes || !node) return
const newId = genId()
const pos = node.position ?? { x: 0, y: 0 }
const newNode = {
id: newId,
type: node.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
}
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `config-${newId}`
setNodes((nds: any[]) => nds.concat(newNode))
}, [node, nodeType, setNodes])
const onCopy = useCallback(() => {
if (!node) return
const copy = { id: node.id, type: node.type, data: node.data, position: node.position, style: node.style }
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => {})
}, [node])
const onReset = useCallback(() => {
if (!setNodes) return
const defaultData = getDefaultDataForType(nodeType, nodeId)
setNodes((nds: any[]) =>
nds.map((n) => (n.id === nodeId ? { ...n, data: defaultData } : n))
)
}, [nodeId, nodeType, setNodes])
const onDelete = useCallback(() => {
if (!setNodes || !setEdges) return
setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId))
setEdges((eds: any[]) => eds.filter((e: any) => e.source !== nodeId && e.target !== nodeId))
}, [nodeId, setNodes, setEdges])
return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-none shadow-none">
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
File
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={onDuplicate}>
Duplicate
</MenubarItem>
<MenubarItem className="text-xs" onClick={onCopy}>
Copy
</MenubarItem>
<MenubarItem className="text-xs" onClick={onReset}>
Reset
</MenubarItem>
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
Delete
</MenubarItem>
</MenubarContent>
</MenubarMenu>
{hasEdit && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Edit
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
<MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{editInputsContent}
</MenubarSubContent>
</MenubarSub>
</MenubarContent>
</MenubarMenu>
)}
</Menubar>
)
}

View File

@@ -10,6 +10,7 @@ import {
BaseNodeFooterText, BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { NodeMenubar } from './NodeMenubar'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
@@ -297,6 +298,9 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={id} /> <BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={id} />
<BaseNodeContent> <BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="render" />
</div>
<div className="min-h-0 flex-1 flex flex-col"> <div className="min-h-0 flex-1 flex flex-col">
{incomingIds.length === 0 ? ( {incomingIds.length === 0 ? (
<Empty className="min-h-0 flex-1"> <Empty className="min-h-0 flex-1">

View File

@@ -7,6 +7,7 @@ import {
BaseNodeFooterText, BaseNodeFooterText,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { NodeMenubar } from './NodeMenubar'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Switch } from '../ui/switch' import { Switch } from '../ui/switch'
@@ -93,6 +94,9 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={id} /> <BaseNodeHeaderRow icon={<Variable className="size-4" />} title={id} />
<BaseNodeContent className="gap-2 p-3"> <BaseNodeContent className="gap-2 p-3">
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="variable" />
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Type</label> <label className="text-xs font-medium text-muted-foreground">Type</label>
<Select value={valueType} onValueChange={onTypeChange}> <Select value={valueType} onValueChange={onTypeChange}>

View File

@@ -73,7 +73,7 @@ const MenubarSubTrigger = React.forwardRef<
<MenubarPrimitive.SubTrigger <MenubarPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className
)} )}

22
src/lib/flowUtils.ts Normal file
View File

@@ -0,0 +1,22 @@
/** Generate short unique node id */
export const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
export 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 DEFAULT_DATA: Record<string, any> = {
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
render: {},
variable: { value: '', valueType: 'string' },
function: { body: '// args[0], args[1], ...\nreturn args[0];' },
}
export function getDefaultDataForType(type: string, newId?: string): any {
const base = { ...DEFAULT_DATA[type] }
if (type === 'config' && newId) base.title = `config-${newId}`
return base
}