diff --git a/web/src/components/canvas/canvas-create-menus.tsx b/web/src/components/canvas/canvas-create-menus.tsx new file mode 100644 index 0000000..52e4085 --- /dev/null +++ b/web/src/components/canvas/canvas-create-menus.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef } from "react"; +import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react"; + +import { canvasThemes } from "@/lib/canvas-theme"; +import { useThemeStore } from "@/stores/use-theme-store"; +import { listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; +import { CanvasNodeType, type ConnectionHandle, type Position } from "@/types/canvas"; + +export type PendingConnectionCreate = { + connection: ConnectionHandle; + position: Position; +}; + +export function ConnectionCreateMenu({ + pending, + onCreate, + onClose, +}: { + pending: PendingConnectionCreate; + onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; + onClose: () => void; +}) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + return ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > +
+ + 引用该节点生成 + + +
+
+ } title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> + } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> + } title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} /> + } title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} /> + } title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} /> +
+
+ ); +} + +export function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) { + return ( + + ); +} + +export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + useNodeRegistryVersion(); + const menuRef = useRef(null); + const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false); + // 点击菜单外的空白处自动关闭 + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose(); + }; + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [onClose]); + return ( +
event.stopPropagation()} + > +
+ + 选择节点 + + +
+
+ {definitions.map((def) => ( + onCreate(def.type)} /> + ))} +
+
+ ); +} diff --git a/web/src/components/canvas/canvas-refresh-shell.tsx b/web/src/components/canvas/canvas-refresh-shell.tsx new file mode 100644 index 0000000..62b4c93 --- /dev/null +++ b/web/src/components/canvas/canvas-refresh-shell.tsx @@ -0,0 +1,34 @@ +export function CanvasRefreshShell() { + return ( +
+
+ +
+ ); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 3ca1c4e..e89c4b5 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react"; +import { BookOpen, Bot, Group, Home, Images, Menu, Plus, Redo2, Trash2, Undo2, Upload, Video } from "lucide-react"; import { saveAs } from "file-saver"; import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image"; @@ -64,11 +64,13 @@ import { resolveMetadataReferences, sourceNodeReferenceImages, } from "@/lib/canvas/canvas-generation-helpers"; -import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; +import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; import { buildNodeContext } from "@/lib/canvas/plugin-node-context"; import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader"; import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes"; import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal"; +import { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell"; +import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus"; import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin"; import { CanvasNodeType, @@ -95,11 +97,6 @@ type CanvasClipboard = { connections: CanvasConnection[]; }; -type PendingConnectionCreate = { - connection: ConnectionHandle; - position: Position; -}; - type ConnectionDropTarget = { nodeId: string | null; isNearNode: boolean; @@ -148,141 +145,6 @@ export default function CanvasPage() { return ; } -function CanvasRefreshShell() { - return ( -
-
- -
- ); -} - -function ConnectionCreateMenu({ - pending, - onCreate, - onClose, -}: { - pending: PendingConnectionCreate; - onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; - onClose: () => void; -}) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - return ( -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > -
- - 引用该节点生成 - - -
-
- } title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> - } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> - } title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} /> - } title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} /> - } title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} /> -
-
- ); -} - -function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) { - return ( - - ); -} - -function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - useNodeRegistryVersion(); - const menuRef = useRef(null); - const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false); - // 点击菜单外的空白处自动关闭 - useEffect(() => { - const handlePointerDown = (event: PointerEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose(); - }; - document.addEventListener("pointerdown", handlePointerDown, true); - return () => document.removeEventListener("pointerdown", handlePointerDown, true); - }, [onClose]); - return ( -
event.stopPropagation()} - > -
- - 选择节点 - - -
-
- {definitions.map((def) => ( - onCreate(def.type)} /> - ))} -
-
- ); -} - function InfiniteCanvasPage() { const { message, modal } = App.useApp(); // 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染