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 { useLayoutEffect } from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useWorkflowPortAdd } from "./workflow-port-add-context"
|
||||||
|
|
||||||
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
||||||
const { selected, node, form } = useNodeRender()
|
const { selected, node, form } = useNodeRender()
|
||||||
|
const requestPortAdd = useWorkflowPortAdd()
|
||||||
const nodeType = String(node.flowNodeType ?? "")
|
const nodeType = String(node.flowNodeType ?? "")
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -33,6 +35,13 @@ export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
|
|||||||
portPrimaryColor="#2575FC"
|
portPrimaryColor="#2575FC"
|
||||||
portSecondaryColor="#c9cdd4"
|
portSecondaryColor="#c9cdd4"
|
||||||
portBackgroundColor="#FFFFFF"
|
portBackgroundColor="#FFFFFF"
|
||||||
|
onPortClick={(port, event) => {
|
||||||
|
if (port.portType !== "output" || typeof event === "function") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.stopPropagation()
|
||||||
|
requestPortAdd?.({ port, event })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{form?.render()}
|
{form?.render()}
|
||||||
</WorkflowNodeRenderer>
|
</WorkflowNodeRenderer>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
EditorRenderer,
|
EditorRenderer,
|
||||||
FreeLayoutEditorProvider,
|
FreeLayoutEditorProvider,
|
||||||
WorkflowDocument,
|
WorkflowDocument,
|
||||||
|
WorkflowLinesManager,
|
||||||
WorkflowSelectService,
|
WorkflowSelectService,
|
||||||
type WorkflowJSON,
|
type WorkflowJSON,
|
||||||
type WorkflowNodeJSON,
|
type WorkflowNodeJSON,
|
||||||
|
type WorkflowPortEntity,
|
||||||
useClientContext,
|
useClientContext,
|
||||||
usePlaygroundTools,
|
usePlaygroundTools,
|
||||||
useService,
|
useService,
|
||||||
@@ -24,7 +26,14 @@ import {
|
|||||||
} from "./workflow-branch-selection"
|
} from "./workflow-branch-selection"
|
||||||
import { WorkflowConfigPanel } from "./workflow-config-sidebar"
|
import { WorkflowConfigPanel } from "./workflow-config-sidebar"
|
||||||
import { WorkflowEditorToolbar } from "./workflow-editor-toolbar"
|
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 {
|
import {
|
||||||
createWorkflowNodeFromSpec,
|
createWorkflowNodeFromSpec,
|
||||||
deleteWorkflowNode,
|
deleteWorkflowNode,
|
||||||
@@ -190,8 +199,11 @@ function WorkflowEditorInner({
|
|||||||
const context = useClientContext()
|
const context = useClientContext()
|
||||||
const playgroundTools = usePlaygroundTools()
|
const playgroundTools = usePlaygroundTools()
|
||||||
const workflowDocument = useService(WorkflowDocument)
|
const workflowDocument = useService(WorkflowDocument)
|
||||||
|
const linesManager = useService(WorkflowLinesManager)
|
||||||
const selectService = useService(WorkflowSelectService)
|
const selectService = useService(WorkflowSelectService)
|
||||||
|
const editorRootRef = useRef<HTMLDivElement>(null)
|
||||||
const [autoLayouting, setAutoLayouting] = useState(false)
|
const [autoLayouting, setAutoLayouting] = useState(false)
|
||||||
|
const [nodeMenu, setNodeMenu] = useState<(WorkflowPortNodeMenuState & { port: WorkflowPortEntity }) | null>(null)
|
||||||
const zoomPercent = `${Math.round(playgroundTools.zoom * 100)}%`
|
const zoomPercent = `${Math.round(playgroundTools.zoom * 100)}%`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -206,13 +218,37 @@ function WorkflowEditorInner({
|
|||||||
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
|
onDefinitionChange(context.document.toJSON() as AIWorkflowDefinition)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNode = async (spec: AIWorkflowNodeSpec) => {
|
const openNodeMenuFromPort = useCallback((request: WorkflowPortAddRequest) => {
|
||||||
const nextNode = createWorkflowNodeFromSpec(spec, definition.nodes, nextNodePosition(definition))
|
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(
|
const created = workflowDocument.createWorkflowNodeByType(
|
||||||
spec.type,
|
spec.type,
|
||||||
nextNode.meta?.position,
|
nextNode.meta?.position,
|
||||||
nextNode as WorkflowNodeJSON
|
nextNode as WorkflowNodeJSON
|
||||||
)
|
)
|
||||||
|
linesManager.createLine({
|
||||||
|
from: sourcePort.node.id,
|
||||||
|
fromPort: sourcePort.portID,
|
||||||
|
to: created.id,
|
||||||
|
toPort: "",
|
||||||
|
})
|
||||||
|
setNodeMenu(null)
|
||||||
await selectService.selectNodeAndScrollToView(created)
|
await selectService.selectNodeAndScrollToView(created)
|
||||||
onSelectNode(created.id)
|
onSelectNode(created.id)
|
||||||
emitCurrentDefinition()
|
emitCurrentDefinition()
|
||||||
@@ -264,15 +300,14 @@ function WorkflowEditorInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={editorRootRef}
|
||||||
data-workflow-editor-root
|
data-workflow-editor-root
|
||||||
className="relative isolate h-full min-h-0 w-full flex-1 overflow-hidden border bg-[var(--g-editor-background)]"
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-[17.5rem] top-3 z-50 max-w-[calc(100%-18.25rem)]",
|
"absolute left-3 top-3 z-50 max-w-[calc(100%-1.5rem)]",
|
||||||
selectedNodeId && "max-w-[calc(100%-41rem)]"
|
selectedNodeId && "max-w-[calc(100%-25rem)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<WorkflowEditorToolbar
|
<WorkflowEditorToolbar
|
||||||
@@ -302,7 +337,17 @@ function WorkflowEditorInner({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<WorkflowConfigPanel
|
||||||
definition={definition}
|
definition={definition}
|
||||||
@@ -318,11 +363,9 @@ function WorkflowEditorInner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextNodePosition(definition: AIWorkflowDefinition) {
|
function nextNodePositionFromPort(port: WorkflowPortEntity) {
|
||||||
if (definition.nodes.length === 0) {
|
return {
|
||||||
return { x: 80, y: 80 }
|
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