feat: implement WorkflowPortAdd context and associated components for enhanced node addition

This commit is contained in:
mlogclub
2026-06-28 21:17:25 +08:00
parent 9373e7d517
commit 4a3482f2f0
5 changed files with 196 additions and 52 deletions
@@ -8,9 +8,11 @@ import {
import { useLayoutEffect } from "react"
import { cn } from "@/lib/utils"
import { useWorkflowPortAdd } from "./workflow-port-add-context"
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
const { selected, node, form } = useNodeRender()
const requestPortAdd = useWorkflowPortAdd()
const nodeType = String(node.flowNodeType ?? "")
useLayoutEffect(() => {
@@ -33,6 +35,13 @@ export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
portPrimaryColor="#2575FC"
portSecondaryColor="#c9cdd4"
portBackgroundColor="#FFFFFF"
onPortClick={(port, event) => {
if (port.portType !== "output" || typeof event === "function") {
return
}
event.stopPropagation()
requestPortAdd?.({ port, event })
}}
>
{form?.render()}
</WorkflowNodeRenderer>
@@ -6,9 +6,11 @@ import {
EditorRenderer,
FreeLayoutEditorProvider,
WorkflowDocument,
WorkflowLinesManager,
WorkflowSelectService,
type WorkflowJSON,
type WorkflowNodeJSON,
type WorkflowPortEntity,
useClientContext,
usePlaygroundTools,
useService,
@@ -24,7 +26,14 @@ import {
} from "./workflow-branch-selection"
import { WorkflowConfigPanel } from "./workflow-config-sidebar"
import { WorkflowEditorToolbar } from "./workflow-editor-toolbar"
import { WorkflowNodePalette } from "./workflow-node-palette"
import {
WorkflowPortAddProvider,
type WorkflowPortAddRequest,
} from "./workflow-port-add-context"
import {
WorkflowPortNodeMenu,
type WorkflowPortNodeMenuState,
} from "./workflow-port-node-menu"
import {
createWorkflowNodeFromSpec,
deleteWorkflowNode,
@@ -190,8 +199,11 @@ function WorkflowEditorInner({
const context = useClientContext()
const playgroundTools = usePlaygroundTools()
const workflowDocument = useService(WorkflowDocument)
const linesManager = useService(WorkflowLinesManager)
const selectService = useService(WorkflowSelectService)
const editorRootRef = useRef<HTMLDivElement>(null)
const [autoLayouting, setAutoLayouting] = useState(false)
const [nodeMenu, setNodeMenu] = useState<(WorkflowPortNodeMenuState & { port: WorkflowPortEntity }) | null>(null)
const zoomPercent = `${Math.round(playgroundTools.zoom * 100)}%`
useEffect(() => {
@@ -206,13 +218,37 @@ function WorkflowEditorInner({
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
}
const addNode = async (spec: AIWorkflowNodeSpec) => {
const nextNode = createWorkflowNodeFromSpec(spec, definition.nodes, nextNodePosition(definition))
const openNodeMenuFromPort = useCallback((request: WorkflowPortAddRequest) => {
const rootRect = editorRootRef.current?.getBoundingClientRect()
setNodeMenu({
port: request.port,
x: rootRect ? request.event.clientX - rootRect.left + 10 : request.event.clientX,
y: rootRect ? request.event.clientY - rootRect.top - 10 : request.event.clientY,
})
}, [])
const addNodeFromPort = async (spec: AIWorkflowNodeSpec) => {
if (!nodeMenu) {
return
}
const sourcePort = nodeMenu.port
const nextNode = createWorkflowNodeFromSpec(
spec,
context.document.toJSON().nodes ?? definition.nodes,
nextNodePositionFromPort(sourcePort)
)
const created = workflowDocument.createWorkflowNodeByType(
spec.type,
nextNode.meta?.position,
nextNode as WorkflowNodeJSON
)
linesManager.createLine({
from: sourcePort.node.id,
fromPort: sourcePort.portID,
to: created.id,
toPort: "",
})
setNodeMenu(null)
await selectService.selectNodeAndScrollToView(created)
onSelectNode(created.id)
emitCurrentDefinition()
@@ -264,15 +300,14 @@ function WorkflowEditorInner({
return (
<div
ref={editorRootRef}
data-workflow-editor-root
className="relative isolate h-full min-h-0 w-full flex-1 overflow-hidden border bg-[var(--g-editor-background)]"
>
<WorkflowNodePalette nodeSpecs={nodeSpecs} onAddNode={addNode} />
<div
className={cn(
"absolute left-[17.5rem] top-3 z-50 max-w-[calc(100%-18.25rem)]",
selectedNodeId && "max-w-[calc(100%-41rem)]"
"absolute left-3 top-3 z-50 max-w-[calc(100%-1.5rem)]",
selectedNodeId && "max-w-[calc(100%-25rem)]"
)}
>
<WorkflowEditorToolbar
@@ -302,7 +337,17 @@ function WorkflowEditorInner({
/>
</div>
<EditorRenderer className="h-full w-full" />
<WorkflowPortAddProvider onRequestAdd={openNodeMenuFromPort}>
<EditorRenderer className="h-full w-full" />
</WorkflowPortAddProvider>
<WorkflowPortNodeMenu
open={Boolean(nodeMenu)}
position={nodeMenu}
nodeSpecs={nodeSpecs}
onSelect={(spec) => void addNodeFromPort(spec)}
onClose={() => setNodeMenu(null)}
/>
<WorkflowConfigPanel
definition={definition}
@@ -318,11 +363,9 @@ function WorkflowEditorInner({
)
}
function nextNodePosition(definition: AIWorkflowDefinition) {
if (definition.nodes.length === 0) {
return { x: 80, y: 80 }
function nextNodePositionFromPort(port: WorkflowPortEntity) {
return {
x: port.point.x + 120,
y: port.point.y - 40,
}
const maxX = Math.max(...definition.nodes.map((node) => node.meta?.position?.x ?? 0))
const minY = Math.min(...definition.nodes.map((node) => node.meta?.position?.y ?? 80))
return { x: maxX + 320, y: Number.isFinite(minY) ? minY : 80 }
}
@@ -1,38 +0,0 @@
"use client"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowNodeIcon } from "./workflow-node-icon"
export function WorkflowNodePalette({
nodeSpecs,
onAddNode,
}: {
nodeSpecs: AIWorkflowNodeSpec[]
onAddNode: (spec: AIWorkflowNodeSpec) => void
}) {
return (
<aside className="absolute left-3 top-3 z-50 flex max-h-[calc(100%-1.5rem)] w-64 shrink-0 flex-col overflow-hidden rounded-md border bg-background/95 shadow-sm backdrop-blur">
<div className="border-b px-3 py-2">
<div className="text-xs font-medium text-muted-foreground"></div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
<div className="space-y-1">
{nodeSpecs.map((spec) => (
<button
key={spec.type}
type="button"
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => onAddNode(spec)}
>
<WorkflowNodeIcon icon={spec.icon} size="sm" className="mt-0.5" />
<span className="min-w-0">
<span className="block truncate font-medium">{spec.title || spec.type}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{spec.description}</span>
</span>
</button>
))}
</div>
</div>
</aside>
)
}
@@ -0,0 +1,30 @@
"use client"
import { createContext, useContext } from "react"
import type { WorkflowPortEntity } from "@flowgram.ai/free-layout-editor"
export type WorkflowPortAddRequest = {
port: WorkflowPortEntity
event: React.MouseEvent<HTMLDivElement>
}
const WorkflowPortAddContext = createContext<((request: WorkflowPortAddRequest) => void) | null>(null)
export function WorkflowPortAddProvider({
onRequestAdd,
children,
}: {
onRequestAdd: (request: WorkflowPortAddRequest) => void
children: React.ReactNode
}) {
return (
<WorkflowPortAddContext.Provider value={onRequestAdd}>
{children}
</WorkflowPortAddContext.Provider>
)
}
export function useWorkflowPortAdd() {
return useContext(WorkflowPortAddContext)
}
@@ -0,0 +1,100 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
import { WorkflowNodeIcon } from "./workflow-node-icon"
export type WorkflowPortNodeMenuState = {
x: number
y: number
}
export function WorkflowPortNodeMenu({
open,
position,
nodeSpecs,
onSelect,
onClose,
}: {
open: boolean
position: WorkflowPortNodeMenuState | null
nodeSpecs: AIWorkflowNodeSpec[]
onSelect: (spec: AIWorkflowNodeSpec) => void
onClose: () => void
}) {
const menuRef = useRef<HTMLDivElement>(null)
const insertableNodeSpecs = useMemo(
() => nodeSpecs.filter((spec) => spec.type !== "start"),
[nodeSpecs]
)
useEffect(() => {
if (!open) {
return
}
const closeOnPointerDown = (event: PointerEvent) => {
const target = event.target
if (target instanceof Node && menuRef.current?.contains(target)) {
return
}
onClose()
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose()
}
}
window.addEventListener("pointerdown", closeOnPointerDown)
window.addEventListener("keydown", closeOnEscape)
return () => {
window.removeEventListener("pointerdown", closeOnPointerDown)
window.removeEventListener("keydown", closeOnEscape)
}
}, [onClose, open])
if (!open || !position) {
return null
}
return (
<div
ref={menuRef}
className="pointer-events-auto absolute z-[80] w-64 overflow-hidden rounded-lg border border-slate-200 bg-white py-1.5 shadow-[0_14px_35px_rgba(15,23,42,0.16)]"
style={{
left: position.x,
top: position.y,
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="border-b border-slate-100 px-3 pb-2 pt-1 text-xs font-medium text-slate-500">
</div>
<div className="max-h-80 overflow-y-auto p-1.5">
{insertableNodeSpecs.map((spec) => (
<button
key={spec.type}
type="button"
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-slate-50"
onClick={(event) => {
event.stopPropagation()
onSelect(spec)
}}
>
<WorkflowNodeIcon icon={spec.icon} size="sm" className="mt-0.5" />
<span className="min-w-0">
<span className="block truncate font-medium text-slate-900">
{spec.title || spec.type}
</span>
{spec.description ? (
<span className="line-clamp-2 text-xs text-slate-500">
{spec.description}
</span>
) : null}
</span>
</button>
))}
</div>
</div>
)
}