From 3a87073dfe1492778ddcb0375aafc50ae467e003 Mon Sep 17 00:00:00 2001 From: xuzhijie Date: Wed, 6 May 2026 12:26:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=BB=E5=B8=83=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=E6=B7=BB=E5=8A=A0=E8=8A=82=E7=82=B9=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CanvasAddNodeMenu.tsx | 270 ++++++++++++++++++ src/components/WorkflowCanvas.tsx | 179 +++++++++++- .../__tests__/WorkflowCanvas.test.tsx | 20 ++ src/components/nodes/VideoInputNode.tsx | 10 +- 4 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 src/components/CanvasAddNodeMenu.tsx diff --git a/src/components/CanvasAddNodeMenu.tsx b/src/components/CanvasAddNodeMenu.tsx new file mode 100644 index 00000000..5c8b1bf6 --- /dev/null +++ b/src/components/CanvasAddNodeMenu.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { NodeType } from "@/types"; + +export type CanvasAddNodeMenuSelection = + | { kind: "node"; type: NodeType } + | { kind: "action"; action: "upload" | "gallery" }; + +interface CanvasAddNodeMenuProps { + position: { x: number; y: number }; + onSelect: (selection: CanvasAddNodeMenuSelection) => void; + onClose: () => void; +} + +interface CanvasAddNodeOption { + id: string; + label: string; + badge?: string; + icon: ReactNode; + selection: CanvasAddNodeMenuSelection; +} + +interface CanvasAddNodeSection { + label: string; + options: CanvasAddNodeOption[]; +} + +const labels = { + addNodes: "\u6dfb\u52a0\u8282\u70b9", + text: "\u6587\u672c", + image: "\u56fe\u7247", + video: "\u89c6\u9891", + videoStitch: "\u89c6\u9891\u5408\u6210", + audio: "\u97f3\u9891", + script: "\u811a\u672c", + addResources: "\u6dfb\u52a0\u8d44\u6e90", + upload: "\u4e0a\u4f20", + fromGallery: "\u4ece\u56fe\u5e93\u9009\u62e9", +}; + +function IconShell({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +const sections: CanvasAddNodeSection[] = [ + { + label: labels.addNodes, + options: [ + { + id: "prompt", + label: labels.text, + selection: { kind: "node", type: "prompt" }, + icon: ( + + + + ), + }, + { + id: "image", + label: labels.image, + selection: { kind: "node", type: "imageInput" }, + icon: ( + + + + + + ), + }, + { + id: "video", + label: labels.video, + selection: { kind: "node", type: "videoInput" }, + icon: ( + + + + + ), + }, + { + id: "video-stitch", + label: labels.videoStitch, + badge: "Beta", + selection: { kind: "node", type: "videoStitch" }, + icon: ( + + + + + ), + }, + { + id: "audio", + label: labels.audio, + selection: { kind: "node", type: "audioInput" }, + icon: ( + + + + + + ), + }, + { + id: "script", + label: labels.script, + badge: "Beta", + selection: { kind: "node", type: "promptConstructor" }, + icon: ( + + + + + + ), + }, + ], + }, + { + label: labels.addResources, + options: [ + { + id: "upload", + label: labels.upload, + selection: { kind: "action", action: "upload" }, + icon: ( + + + + + + ), + }, + { + id: "gallery", + label: labels.fromGallery, + selection: { kind: "action", action: "gallery" }, + icon: ( + + + + + + ), + }, + ], + }, +]; + +export function CanvasAddNodeMenu({ + position, + onSelect, + onClose, +}: CanvasAddNodeMenuProps) { + const menuRef = useRef(null); + const options = useMemo(() => sections.flatMap((section) => section.options), []); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (menuRef.current?.contains(event.target as Node)) return; + onClose(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [onClose]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((current) => (current + 1) % options.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((current) => (current - 1 + options.length) % options.length); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + const option = options[selectedIndex]; + if (option) onSelect(option.selection); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose, onSelect, options, selectedIndex]); + + const left = typeof window === "undefined" + ? position.x + : Math.max(12, Math.min(position.x, window.innerWidth - 272)); + const top = typeof window === "undefined" + ? position.y + : Math.max(12, Math.min(position.y, window.innerHeight - 520)); + let optionIndex = -1; + + return ( +
+ {sections.map((section, sectionIndex) => ( +
+
+ {section.label} +
+
+ {section.options.map((option) => { + optionIndex += 1; + const currentIndex = optionIndex; + const isSelected = currentIndex === selectedIndex; + + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 40226bea..bf46d70e 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useCallback, useRef, useState, useEffect, DragEvent, useMemo } from "react"; +import { Fragment, useCallback, useRef, useState, useEffect, DragEvent, ChangeEvent, useMemo } from "react"; import { ReactFlow, Background, @@ -60,6 +60,7 @@ import { NodeQuickAddControls, QuickAddSide } from "./NodeQuickAddControls"; import { ImageNodeUploadOverlay } from "./ImageNodeUploadOverlay"; import { VideoNodeUploadOverlay } from "./VideoNodeUploadOverlay"; import { ImageNodeComposerPanel } from "./ImageNodeComposerPanel"; +import { CanvasAddNodeMenu, type CanvasAddNodeMenuSelection } from "./CanvasAddNodeMenu"; import { NodeType, NanoBananaNodeData, GenerateVideoNodeData, HandleType, PromptNodeData, LLMGenerateNodeData, PromptConstructorNodeData, AvailableVariable, ImageInputNodeData, VideoInputNodeData } from "@/types"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader"; @@ -410,6 +411,10 @@ export function WorkflowCanvas() { const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false); const [showNewProjectSetup, setShowNewProjectSetup] = useState(false); const [expandingNode, setExpandingNode] = useState<{ id: string; type: string } | null>(null); + const [canvasAddMenu, setCanvasAddMenu] = useState<{ + screenPosition: { x: number; y: number }; + flowPosition: { x: number; y: number }; + } | null>(null); // Fallback model picker state const [fallbackDialogState, setFallbackDialogState] = useState< @@ -418,6 +423,8 @@ export function WorkflowCanvas() { >(null); const [llmFallbackState, setLlmFallbackState] = useState<{ nodeId: string } | null>(null); const reactFlowWrapper = useRef(null); + const canvasUploadInputRef = useRef(null); + const canvasUploadPositionRef = useRef<{ x: number; y: number } | null>(null); const tutorialViewportSet = useRef(false); // FTUX tutorial state (client-side only to avoid SSR hydration issues) @@ -666,7 +673,7 @@ export function WorkflowCanvas() { const handleQuickAddHoverChange = useCallback( (nodeId: string, isHovered: boolean) => { - setHoveredNodeId(isHovered ? nodeId : null); + setHoveredNodeId?.(isHovered ? nodeId : null); }, [setHoveredNodeId] ); @@ -2182,6 +2189,152 @@ export function WorkflowCanvas() { [screenToFlowPosition, addNode, updateNodeData, loadWorkflow] ); + const createUploadedMediaNodes = useCallback( + (files: File[], basePosition: { x: number; y: number }) => { + let createdIndex = 0; + const nextPosition = () => ({ + x: basePosition.x + createdIndex++ * 240, + y: basePosition.y, + }); + + files.forEach((file) => { + if (file.type.startsWith("image/")) { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl !== "string") return; + + const img = new Image(); + img.onload = () => { + const nodeId = addNode("imageInput", nextPosition()); + updateNodeData(nodeId, { + image: dataUrl, + filename: file.name, + dimensions: { width: img.width, height: img.height }, + }); + }; + img.src = dataUrl; + }; + reader.readAsDataURL(file); + return; + } + + if (file.type.startsWith("video/")) { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl !== "string") return; + + const nodeId = addNode("videoInput", nextPosition()); + updateNodeData(nodeId, { + video: dataUrl, + videoRef: undefined, + filename: file.name, + format: file.type, + duration: null, + dimensions: null, + }); + }; + reader.readAsDataURL(file); + return; + } + + if (file.type.startsWith("audio/")) { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl !== "string") return; + + const nodeId = addNode("audioInput", nextPosition()); + updateNodeData(nodeId, { + audioFile: dataUrl, + filename: file.name, + format: file.type, + }); + }; + reader.readAsDataURL(file); + return; + } + + showToast(`不支持的文件类型:${file.name}`, "warning"); + }); + }, + [addNode, updateNodeData, showToast] + ); + + const handleCanvasUploadChange = useCallback( + (event: ChangeEvent) => { + const files = Array.from(event.currentTarget.files ?? []); + const position = canvasUploadPositionRef.current; + event.currentTarget.value = ""; + canvasUploadPositionRef.current = null; + + if (files.length === 0 || !position) return; + createUploadedMediaNodes(files, position); + }, + [createUploadedMediaNodes] + ); + + const handleCanvasPaneDoubleClick = useCallback( + (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if ( + target.closest( + [ + "[data-canvas-add-menu]", + ".react-flow__node", + ".react-flow__edge", + ".react-flow__controls", + ".react-flow__minimap", + ".react-flow__attribution", + "button", + "input", + "textarea", + "select", + "a", + ].join(", ") + ) + ) { + return; + } + + if (!target.closest(".react-flow")) return; + event.preventDefault(); + event.stopPropagation(); + + const screenPosition = { x: event.clientX, y: event.clientY }; + setCanvasAddMenu({ + screenPosition, + flowPosition: screenToFlowPosition(screenPosition), + }); + }, + [screenToFlowPosition] + ); + + const handleCanvasAddMenuSelect = useCallback( + (selection: CanvasAddNodeMenuSelection) => { + if (!canvasAddMenu) return; + + if (selection.kind === "node") { + addNode(selection.type, canvasAddMenu.flowPosition); + setCanvasAddMenu(null); + return; + } + + if (selection.action === "upload") { + canvasUploadPositionRef.current = canvasAddMenu.flowPosition; + setCanvasAddMenu(null); + requestAnimationFrame(() => canvasUploadInputRef.current?.click()); + return; + } + + addNode("outputGallery", canvasAddMenu.flowPosition); + setCanvasAddMenu(null); + showToast("已添加图库节点,可继续连接输出或拖拽历史图片。", "info"); + }, + [addNode, canvasAddMenu, showToast] + ); + return (
+ + {/* Drop overlay indicator */} {isDragOver && (
@@ -2257,7 +2421,7 @@ export function WorkflowCanvas() { onEdgesChange={onEdgesChange} onConnect={handleConnect} onConnectEnd={handleConnectEnd} - onMoveStart={() => { isPanningRef.current = true; setHoveredNodeId(null); document.documentElement.classList.add("canvas-interacting"); }} + onMoveStart={() => { isPanningRef.current = true; setHoveredNodeId?.(null); document.documentElement.classList.add("canvas-interacting"); }} onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); }} onNodeDragStart={() => { isDraggingNodeRef.current = true; document.documentElement.classList.add("canvas-interacting"); }} onNodeDragStop={(event, node) => { isDraggingNodeRef.current = false; document.documentElement.classList.remove("canvas-interacting"); handleNodeDragStop(event, node); }} @@ -2297,6 +2461,7 @@ export function WorkflowCanvas() { nodeClickDistance={5} zoomOnScroll={tutorialActive ? false : false} zoomOnPinch={tutorialActive ? false : !isModalOpen} + zoomOnDoubleClick={false} minZoom={0.1} maxZoom={4} defaultViewport={{ x: 0, y: 0, zoom: 1 }} @@ -2562,6 +2727,14 @@ export function WorkflowCanvas() { /> )} + {canvasAddMenu && ( + setCanvasAddMenu(null)} + /> + )} + {/* Multi-select toolbar */} diff --git a/src/components/__tests__/WorkflowCanvas.test.tsx b/src/components/__tests__/WorkflowCanvas.test.tsx index 64ee37f0..16e1cbee 100644 --- a/src/components/__tests__/WorkflowCanvas.test.tsx +++ b/src/components/__tests__/WorkflowCanvas.test.tsx @@ -326,6 +326,26 @@ describe("WorkflowCanvas", () => { }); }); + describe("Canvas Add Node Menu", () => { + it("opens on canvas double click and creates a selected node", async () => { + render( + + + + ); + + const pane = document.querySelector(".react-flow__pane") as HTMLElement; + fireEvent.doubleClick(pane, { clientX: 240, clientY: 260 }); + + expect(await screen.findByText("添加节点")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "图片" })); + + expect(mockScreenToFlowPosition).toHaveBeenCalledWith({ x: 240, y: 260 }); + expect(mockAddNode).toHaveBeenCalledWith("imageInput", { x: 240, y: 260 }); + expect(screen.queryByText("添加节点")).not.toBeInTheDocument(); + }); + }); + describe("Edge Types Registration", () => { it("should register editable and reference edge types for the canvas", () => { // Edge types are registered at module level diff --git a/src/components/nodes/VideoInputNode.tsx b/src/components/nodes/VideoInputNode.tsx index 525c6ea7..0833bdd1 100644 --- a/src/components/nodes/VideoInputNode.tsx +++ b/src/components/nodes/VideoInputNode.tsx @@ -14,7 +14,7 @@ import { readVideoFile } from "@/utils/videoFile"; type VideoInputNodeType = Node; -// 空视频节点的快捷入口文案;使用转义写法,避免不同终端编码导致中文被写坏。 +// 空视频节点的快捷入口文案;使用转义写法,避免终端编码差异写坏中文。 const TRY_LABEL = "\u5c1d\u8bd5:"; const KEYFRAMES_ACTION_LABEL = "\u9996\u5c3e\u5e27\u751f\u6210\u89c6\u9891"; const FIRST_FRAME_ACTION_LABEL = "\u9996\u5e27\u751f\u6210\u89c6\u9891"; @@ -33,7 +33,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps 视频节点”的链路,视频文件由用户后续在各节点上传。 + // 只搭建“首帧/尾帧 -> 视频节点”的链路,视频文件由用户后续在各节点上传。 const createVideoNodeChain = useCallback( (hasLastFrame: boolean) => { const currentNode = nodes.find((node) => node.id === id); @@ -47,7 +47,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps - + {nodeData.isOptional ? ( Optional