Browse Source

新增画布双击添加节点菜单

xuzhijie-popiart-node-canvas^2
xuzhijie 2 months ago
parent
commit
3a87073dfe
  1. 270
      src/components/CanvasAddNodeMenu.tsx
  2. 179
      src/components/WorkflowCanvas.tsx
  3. 20
      src/components/__tests__/WorkflowCanvas.test.tsx
  4. 10
      src/components/nodes/VideoInputNode.tsx

270
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 (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-700 text-neutral-100">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
</span>
);
}
const sections: CanvasAddNodeSection[] = [
{
label: labels.addNodes,
options: [
{
id: "prompt",
label: labels.text,
selection: { kind: "node", type: "prompt" },
icon: (
<IconShell>
<path d="M5 7h14M5 12h14M5 17h10" />
</IconShell>
),
},
{
id: "image",
label: labels.image,
selection: { kind: "node", type: "imageInput" },
icon: (
<IconShell>
<path d="M4 5h16v14H4z" />
<path d="m4 15 4-4 4 4 2-2 6 6" />
<path d="M15.5 8.5h.01" />
</IconShell>
),
},
{
id: "video",
label: labels.video,
selection: { kind: "node", type: "videoInput" },
icon: (
<IconShell>
<path d="M4 6h11v12H4z" />
<path d="m15 10 5-3v10l-5-3z" />
</IconShell>
),
},
{
id: "video-stitch",
label: labels.videoStitch,
badge: "Beta",
selection: { kind: "node", type: "videoStitch" },
icon: (
<IconShell>
<path d="M4 7h16M4 12h16M4 17h16" />
<path d="M9 5v14M15 5v14" />
</IconShell>
),
},
{
id: "audio",
label: labels.audio,
selection: { kind: "node", type: "audioInput" },
icon: (
<IconShell>
<path d="M4 12h3l4-5v10l-4-5H4z" />
<path d="M15 9.5a4 4 0 0 1 0 5" />
<path d="M18 7a8 8 0 0 1 0 10" />
</IconShell>
),
},
{
id: "script",
label: labels.script,
badge: "Beta",
selection: { kind: "node", type: "promptConstructor" },
icon: (
<IconShell>
<path d="M8 6 4 12l4 6" />
<path d="m16 6 4 6-4 6" />
<path d="m14 4-4 16" />
</IconShell>
),
},
],
},
{
label: labels.addResources,
options: [
{
id: "upload",
label: labels.upload,
selection: { kind: "action", action: "upload" },
icon: (
<IconShell>
<path d="M12 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M5 20h14" />
</IconShell>
),
},
{
id: "gallery",
label: labels.fromGallery,
selection: { kind: "action", action: "gallery" },
icon: (
<IconShell>
<path d="M5 5h14v14H5z" />
<path d="m7 15 3-3 2 2 2-3 3 4" />
<path d="M8.5 8.5h.01" />
</IconShell>
),
},
],
},
];
export function CanvasAddNodeMenu({
position,
onSelect,
onClose,
}: CanvasAddNodeMenuProps) {
const menuRef = useRef<HTMLDivElement>(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 (
<div
ref={menuRef}
data-canvas-add-menu
className="fixed z-[120] w-[240px] max-h-[calc(100vh-24px)] select-none overflow-y-auto rounded-xl border border-neutral-700 bg-neutral-800/95 p-4 shadow-2xl shadow-black/40 outline-none backdrop-blur"
style={{ left, top }}
>
{sections.map((section, sectionIndex) => (
<div key={section.label} className={sectionIndex === 0 ? "" : "mt-4"}>
<div className="mb-2 text-sm font-medium text-neutral-400">
{section.label}
</div>
<div className="space-y-1">
{section.options.map((option) => {
optionIndex += 1;
const currentIndex = optionIndex;
const isSelected = currentIndex === selectedIndex;
return (
<button
key={option.id}
type="button"
onClick={() => onSelect(option.selection)}
onMouseEnter={() => setSelectedIndex(currentIndex)}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-sm font-semibold transition-colors ${
isSelected
? "bg-neutral-700 text-white"
: "text-neutral-200 hover:bg-neutral-700/80 hover:text-white"
}`}
>
{option.icon}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.badge && (
<span className="rounded bg-neutral-600 px-1.5 py-0.5 text-[10px] font-semibold text-neutral-300">
{option.badge}
</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
);
}

179
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<HTMLDivElement>(null);
const canvasUploadInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
ref={reactFlowWrapper}
@ -2189,7 +2342,18 @@ export function WorkflowCanvas() {
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDoubleClickCapture={handleCanvasPaneDoubleClick}
>
<input
ref={canvasUploadInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,video/mp4,video/webm,video/quicktime,audio/*"
className="hidden"
aria-label="Upload media to canvas"
onChange={handleCanvasUploadChange}
/>
{/* Drop overlay indicator */}
{isDragOver && (
<div className="absolute inset-0 bg-blue-500/10 z-50 pointer-events-none flex items-center justify-center">
@ -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 && (
<CanvasAddNodeMenu
position={canvasAddMenu.screenPosition}
onSelect={handleCanvasAddMenuSelect}
onClose={() => setCanvasAddMenu(null)}
/>
)}
{/* Multi-select toolbar */}
<MultiSelectToolbar />

20
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(
<TestWrapper>
<WorkflowCanvas />
</TestWrapper>
);
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

10
src/components/nodes/VideoInputNode.tsx

@ -14,7 +14,7 @@ import { readVideoFile } from "@/utils/videoFile";
type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">;
// 空视频节点的快捷入口文案;使用转义写法,避免不同终端编码导致中文被写坏
// 空视频节点的快捷入口文案;使用转义写法,避免终端编码差异写坏中文
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<VideoInputNodeT
// Use blob URL for efficient playback of large base64 videos
const playbackUrl = useVideoBlobUrl(nodeData.video ?? null);
// 只负责搭建“首帧/尾帧 -> 视频节点”的链路,视频文件由用户后续在各节点上传。
// 只搭建“首帧/尾帧 -> 视频节点”的链路,视频文件由用户后续在各节点上传。
const createVideoNodeChain = useCallback(
(hasLastFrame: boolean) => {
const currentNode = nodes.find((node) => node.id === id);
@ -47,7 +47,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
const targetVideoHeight = defaultNodeDimensions.videoInput.height;
const verticalGap = 52;
// 复用当前空视频节点作为首帧节点,避免点击快捷入口后留下多余空节点。
// 复用当前空视频节点作为首帧节点,避免留下多余空节点。
updateNodeData(id, { label: FIRST_FRAME_LABEL });
const lastFrameNodeId = hasLastFrame
@ -65,7 +65,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
? currentPosition.y + currentHeight + verticalGap / 2 - targetVideoHeight / 2
: currentPosition.y;
// 右侧目标同样创建为空视频节点,用户后续可以自行上传或继续接入下一步
// 右侧目标也创建为空视频节点,保持首尾帧链路全部由视频节点组成
const targetVideoNodeId = addNode(
"videoInput",
{
@ -191,7 +191,7 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
className={`w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center transition-colors ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`}
>
<svg className="w-10 h-10 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9A2.25 2.25 0 0013.5 5.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25Z" />
</svg>
{nodeData.isOptional ? (
<span className="text-xs text-neutral-500 mt-2">Optional</span>

Loading…
Cancel
Save