contex improvements
This commit is contained in:
133
src/App.tsx
133
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">${{id}(var1, var2)}</code>
|
Call in config: <code className="rounded bg-muted px-0.5">${{id}(var1, var2)}</code>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
112
src/components/graph/NodeMenubar.tsx
Normal file
112
src/components/graph/NodeMenubar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
22
src/lib/flowUtils.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user