feat: implement WorkflowPortAdd context and associated components for enhanced node addition
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user