From 6ee54c839993a4abc712b275ecc2efced682b141 Mon Sep 17 00:00:00 2001 From: eust-w Date: Fri, 27 Mar 2026 12:08:51 +0800 Subject: [PATCH] :sparkles: add canvas node drag drop and connection guards --- README.md | 3 +- apps/web/src/runtime/app.tsx | 126 +++++++++++++++- apps/web/src/runtime/i18n.test.ts | 8 + apps/web/src/runtime/i18n.tsx | 31 +++- .../src/runtime/workflow-editor-state.test.ts | 138 +++++++++++++++++- apps/web/src/runtime/workflow-editor-state.ts | 136 +++++++++++++++-- apps/web/src/styles.css | 40 +++++ ...nformation-architecture-and-key-screens.md | 11 ++ ...26-03-26-emboflow-v1-foundation-and-mvp.md | 1 + 9 files changed, 472 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9a8db24..bed4661 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ You can register that directory from the Assets page or via `POST /api/assets/re The workflow editor currently requires selecting at least one registered asset before a run can be created. The editor now also persists per-node runtime config in workflow versions, including executor overrides, optional artifact title overrides, and Python code-hook source for inspect and transform style nodes. The runtime web shell now exposes a visible `中文 / English` language toggle. The core workspace shell and workflow authoring surface are translated through a lightweight i18n layer. -The workflow editor center panel now uses a real draggable node canvas with zoom, pan, mini-map, dotted background, handle-based edge creation, and persisted node positions instead of a static list of node cards. +The workflow editor center panel now uses a real draggable node canvas with zoom, pan, mini-map, dotted background, handle-based edge creation, persisted node positions, and localized validation feedback instead of a static list of node cards. +The node library now supports both click-to-append and drag-and-drop placement into the canvas. V1 connection rules block self-edges, duplicate edges, cycles, incoming edges into source nodes, outgoing edges from export nodes, and multiple upstream edges into a single node. The Runs workspace now shows project-scoped run history, run-level aggregated summaries, cancel/retry controls, and run detail views with persisted task summaries, stdout/stderr sections, result previews, and artifact links into Explore. Selected run tasks now expose the frozen node definition id, executor config snapshot, and code-hook metadata that were captured when the run was created. When a node uses `executorType=docker` and provides `executorConfig.image`, the worker now runs a real local Docker container with mounted `input.json` / `output.json` exchange files. If no image is configured, the executor falls back to the lightweight simulated behavior used by older demo tasks. diff --git a/apps/web/src/runtime/app.tsx b/apps/web/src/runtime/app.tsx index 8b8a7ce..b6baa0a 100644 --- a/apps/web/src/runtime/app.tsx +++ b/apps/web/src/runtime/app.tsx @@ -9,13 +9,20 @@ import { type Edge, type Node, type NodeChange, + type ReactFlowInstance, type Viewport, } from "@xyflow/react"; import { ApiClient } from "./api-client.ts"; -import { localizeNodeDefinition, useI18n } from "./i18n.tsx"; +import { + localizeNodeDefinition, + type TranslationKey, + useI18n, +} from "./i18n.tsx"; import { addNodeToDraft, + addNodeToDraftAtPosition, + canConnectNodesInDraft, connectNodesInDraft, createDefaultWorkflowDraft, getNodeRuntimeConfig, @@ -26,10 +33,36 @@ import { setViewportInDraft, setNodeRuntimeConfig, workflowDraftFromVersion, + type WorkflowConnectionValidationReason, type WorkflowDraft, type WorkflowNodeRuntimeConfig, } from "./workflow-editor-state.ts"; +const NODE_LIBRARY_MIME = "application/x-emboflow-node-definition"; + +function mapConnectionValidationReasonToKey( + reason: WorkflowConnectionValidationReason | "missing_connection_endpoint", +): TranslationKey { + switch (reason) { + case "missing_source": + case "missing_target": + case "missing_connection_endpoint": + return "invalidConnectionMissingEndpoint"; + case "self": + return "invalidConnectionSelf"; + case "duplicate": + return "invalidConnectionDuplicate"; + case "source_disallows_outgoing": + return "invalidConnectionSourceDisallowsOutgoing"; + case "target_disallows_incoming": + return "invalidConnectionTargetDisallowsIncoming"; + case "target_already_has_incoming": + return "invalidConnectionTargetAlreadyHasIncoming"; + case "cycle": + return "invalidConnectionCycle"; + } +} + type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin"; type BootstrapContext = { @@ -446,6 +479,9 @@ function WorkflowEditorPage(props: { const [lastRunId, setLastRunId] = useState(null); const [dirty, setDirty] = useState(false); const [error, setError] = useState(null); + const [canvasFeedbackKey, setCanvasFeedbackKey] = useState(null); + const [canvasDropActive, setCanvasDropActive] = useState(false); + const [flowInstance, setFlowInstance] = useState | null>(null); useEffect(() => { void (async () => { @@ -470,6 +506,7 @@ function WorkflowEditorPage(props: { setDraft(nextDraft); setSelectedNodeId(nextDraft.logicGraph.nodes[0]?.id ?? "rename-folder"); setDirty(false); + setCanvasFeedbackKey(null); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : t("failedLoadWorkflow")); } @@ -480,6 +517,10 @@ function WorkflowEditorPage(props: { () => nodes.map((node) => localizeNodeDefinition(language, node)), [language, nodes], ); + const nodeDefinitionsById = useMemo( + () => new Map(nodes.map((node) => [node.id, node])), + [nodes], + ); const selectedNodeDefinitionId = useMemo( () => resolveDefinitionIdForNode(draft, selectedNodeId), @@ -571,8 +612,17 @@ function WorkflowEditorPage(props: { }, []); const onCanvasConnect = useCallback((connection: Connection) => { - setDraft((current) => connectNodesInDraft(current, connection.source, connection.target)); - setDirty(true); + setDraft((current) => { + const validation = canConnectNodesInDraft(current, connection.source, connection.target); + if (!validation.ok) { + setCanvasFeedbackKey(mapConnectionValidationReasonToKey(validation.reason)); + return current; + } + + setCanvasFeedbackKey(null); + setDirty(true); + return connectNodesInDraft(current, connection.source, connection.target); + }); }, []); const onViewportChangeEnd = useCallback((viewport: Viewport) => { @@ -586,6 +636,50 @@ function WorkflowEditorPage(props: { setDirty(true); }, []); + const handleNodeLibraryDragStart = useCallback((event: React.DragEvent, nodeId: string) => { + event.dataTransfer.setData(NODE_LIBRARY_MIME, nodeId); + event.dataTransfer.effectAllowed = "copy"; + }, []); + + const handleCanvasDragOver = useCallback((event: React.DragEvent) => { + if (!event.dataTransfer.types.includes(NODE_LIBRARY_MIME)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setCanvasDropActive(true); + }, []); + + const handleCanvasDrop = useCallback((event: React.DragEvent) => { + const definitionId = event.dataTransfer.getData(NODE_LIBRARY_MIME); + if (!definitionId) { + setCanvasDropActive(false); + return; + } + + event.preventDefault(); + const definition = nodeDefinitionsById.get(definitionId); + if (!definition) { + setCanvasDropActive(false); + setCanvasFeedbackKey("invalidConnectionMissingEndpoint"); + return; + } + + const droppedAt = flowInstance + ? flowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }) + : { x: event.clientX, y: event.clientY }; + const result = addNodeToDraftAtPosition(draft, definition, droppedAt); + setDraft(result.draft); + setSelectedNodeId(result.nodeId); + setDirty(true); + setCanvasFeedbackKey(null); + setCanvasDropActive(false); + }, [draft, flowInstance, nodeDefinitionsById]); + + const handleCanvasDragLeave = useCallback(() => { + setCanvasDropActive(false); + }, []); + function updateSelectedNodeRuntimeConfig( nextConfig: WorkflowNodeRuntimeConfig | ((current: WorkflowNodeRuntimeConfig) => WorkflowNodeRuntimeConfig), ) { @@ -678,16 +772,21 @@ function WorkflowEditorPage(props: {