import { AnimatePresence, useReducedMotion } from "motion/react"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { canvasThemes } from "@/lib/canvas-theme"; import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset"; import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame"; import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry"; import { useThemeStore } from "@/stores/use-theme-store"; import { usePluginStore } from "@/stores/use-plugin-store"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas"; type CanvasAssetCategory = NonNullable["assetCategory"]>; const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [ { value: "character", label: "角色" }, { value: "environment", label: "场景" }, { value: "wardrobe", label: "服饰" }, { value: "prop", label: "道具" }, { value: "weapon", label: "武器" }, { value: "style", label: "画风" }, { value: "other", label: "其他" }, ]; type CanvasNodeContextMenuProps = { menu: ContextMenuState; node?: CanvasNodeData | null; workspaceMode?: CanvasWorkspaceMode; isProjectLinked?: boolean; canUndo: boolean; canRedo: boolean; canPaste: boolean; onClose: () => void; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: (position: Position) => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void; onUndo: () => void; onRedo: () => void; onPaste: () => void; onCopyNode: () => void; onDuplicate: () => void; onDelete: () => void; onSaveAsset: () => void; onViewMedia: () => void; onEditText: () => void; onOpenDrawing: () => void; onGenerateImage: () => void; onCopyContent: () => void; onCopyMediaUrl: () => void; onUploadToArkPrivateAsset: () => void; onSetAssetCategory: (category: CanvasAssetCategory) => void; onToggleFrame: () => void; }; export function CanvasNodeContextMenu({ menu, node, workspaceMode = "professional", isProjectLinked = false, canUndo, canRedo, canPaste, onClose, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters, onUndo, onRedo, onPaste, onCopyNode, onDuplicate, onDelete, onSaveAsset, onViewMedia, onEditText, onOpenDrawing, onGenerateImage, onCopyContent, onCopyMediaUrl, onUploadToArkPrivateAsset, onSetAssetCategory, onToggleFrame, }: CanvasNodeContextMenuProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const reducedMotion = useReducedMotion(); const [addOpen, setAddOpen] = useState(false); const [categoryOpen, setCategoryOpen] = useState(false); useEffect(() => { const close = (event: PointerEvent) => { const target = event.target; if (target instanceof Element && target.closest(".ant-popover")) return; onClose(); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; if (categoryOpen) setCategoryOpen(false); else onClose(); }; window.addEventListener("pointerdown", close); window.addEventListener("keydown", closeOnEscape); return () => { window.removeEventListener("pointerdown", close); window.removeEventListener("keydown", closeOnEscape); }; }, [categoryOpen, onClose]); useEffect(() => { setAddOpen(false); setCategoryOpen(false); }, [menu.type, menu.x, menu.y]); const runAction = (action: () => void) => { action(); onClose(); }; const nodeContent = typeof node?.metadata?.content === "string" ? node.metadata.content : ""; const isImage = node?.type === CanvasNodeType.Image; const isText = node?.type === CanvasNodeType.Text; const isCharacterReference = Boolean(isText && node?.metadata?.workflowKind === "character" && node.metadata.characterAssetId); const isDrawing = node?.type === CanvasNodeType.Drawing; const isVideo = node?.type === CanvasNodeType.Video; const isMedia = isImage || isVideo; const isAudio = node?.type === CanvasNodeType.Audio; const isFrame = node?.type === CanvasNodeType.Frame; const isFolder = isCanvasFolderNode(node); const hasNodeContent = isText ? Boolean(nodeContent.trim()) : Boolean(nodeContent); const canSaveAsset = Boolean(node && !isCharacterReference && (isText ? hasNodeContent : hasNodeContent && (isImage || isVideo || isAudio))); const canOpenPreview = Boolean(isMedia && hasNodeContent); const canGenerateFromText = Boolean(isText && !isCharacterReference && hasNodeContent); const canCopyMediaUrl = Boolean(isMedia && hasNodeContent); const assetCategory = node ? canvasNodeAssetCategory(node) : "other"; const position = getContextMenuPosition(menu); return ( <> event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} >
{menu.type === "node" && isMedia && categoryOpen ? ( <> setCategoryOpen(false)} /> {assetCategoryOptions.map((option) => ( : } label={option.label} active={assetCategory === option.value} onClick={() => runAction(() => onSetAssetCategory(option.value))} /> ))} ) : menu.type === "canvas" ? ( <> } label="添加节点" chevron active={addOpen} onClick={() => setAddOpen((value) => !value)} /> } label="上传到这里" onClick={() => runAction(onUpload)} /> {!isProjectLinked ? } label="从素材库插入" onClick={() => runAction(onOpenAssets)} /> : null} } label="撤销" shortcut="⌘Z" disabled={!canUndo} onClick={() => runAction(onUndo)} /> } label="重做" shortcut="⇧⌘Z" disabled={!canRedo} onClick={() => runAction(onRedo)} /> } label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} /> ) : menu.type === "node" ? ( <> {isCharacterReference ? ( <> } label="查看角色详情" onClick={() => runAction(onEditText)} /> } label="复制角色引用" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> } label="创建引用副本" shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="删除节点" danger onClick={() => runAction(onDelete)} /> ) : isMedia ? ( <> } label="进入全景预览" disabled={!canOpenPreview} onClick={() => runAction(onViewMedia)} /> } label="设置资产分类" chevron onClick={() => setCategoryOpen(true)} /> {isImage ? } label="上传到方舟素材库" onClick={() => runAction(onUploadToArkPrivateAsset)} /> : null} } label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isImage ? } label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} /> } label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="删除节点" danger onClick={() => runAction(onDelete)} /> ) : ( <> {isFrame ? : } label={node?.metadata?.frame?.collapsed ? `展开${isFolder ? "文件夹" : "背板"}` : `折叠${isFolder ? "文件夹" : "背板"}`} onClick={() => runAction(onToggleFrame)} /> : } label="保存到我的素材" disabled={!canSaveAsset} onClick={() => runAction(onSaveAsset)} />} {isText ? } label="放大编辑" onClick={() => runAction(onEditText)} /> : null} {isDrawing ? } label="打开绘图" onClick={() => runAction(onOpenDrawing)} /> : null} {isText ? } label="用文本生图" disabled={!canGenerateFromText} onClick={() => runAction(onGenerateImage)} /> : null} } label={isFrame ? `复制${isFolder ? "文件夹" : "背板"}及内容` : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isText ? } label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isFrame ? `创建${isFolder ? "文件夹" : "背板"}副本` : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} /> } label={isFrame ? `删除${isFolder ? "文件夹" : "背板"}` : "删除节点"} danger onClick={() => runAction(onDelete)} /> )} ) : ( <> } label="删除连接" danger onClick={() => runAction(onDelete)} /> )}
{menu.type === "canvas" && addOpen ? ( runAction(() => onAddNode(type))} onAddFolder={() => runAction(onAddFolder)} onChooseStyle={() => runAction(onChooseStyle)} onOpenDirector={() => runAction(() => onOpenDirector(menu.position))} onUpload={() => runAction(onUpload)} onOpenAssets={() => runAction(onOpenAssets)} onOpenProjectCharacters={() => runAction(onOpenProjectCharacters)} /> ) : null} ); } function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const installations = usePluginStore((state) => state.installations); const pluginStates = usePluginStore((state) => state.pluginStates); const left = getSubmenuLeft(parentPosition.left); const createContext: AddNodeMenuContext = { workspaceMode, isProjectLinked, enabledPluginIds: new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id)), handlers: { onAddText: () => onAddNode(CanvasNodeType.Text), onAddImage: () => onAddNode(CanvasNodeType.Image), onAddVideo: () => onAddNode(CanvasNodeType.Video), onAddAudio: () => onAddNode(CanvasNodeType.Audio), onAddScript: () => onAddNode(CanvasNodeType.Script), onAddFrame: () => onAddNode(CanvasNodeType.Frame), onAddFolder, onAddDrawing: () => onAddNode(CanvasNodeType.Drawing), onAddExtensionNode: onAddNode, onAddWorkflow: () => onAddNode(CanvasNodeType.Config), onChooseStyle, onOpenDirector, onUpload, onOpenMyAssets: onOpenAssets, onOpenProjectCharacters, }, }; const commands: CanvasCreateCommand[] = resolveAddNodeMenuCommands(createContext).map((command) => ({ id: command.id, label: command.label, icon: command.icon, badge: command.badge, section: command.section, onClick: () => command.run(createContext), })); return ( parentPosition.left ? -5 : 5, scale: 0.97 }} animate={{ opacity: 1, x: 0, scale: 1 }} exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl" style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onContextMenu={(event) => event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} >
); } function MenuHeader({ title, description, onBack }: { title: string; description?: string; onBack?: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return (
{onBack ? : null} {title}{description && description !== title ? {description} : null}
); } function MenuSection({ label }: { label: string }) { return
{label}
; } function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, active = false, disabled = false, danger = false, onClick }: { icon: ReactNode; label: string; detail?: string; shortcut?: string; badge?: string; chevron?: boolean; active?: boolean; disabled?: boolean; danger?: boolean; onClick?: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const color = danger ? theme.accent.danger : theme.node.text; return ( ); } function MenuDivider() { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return
; } function getContextMenuPosition(menu: ContextMenuState) { if (typeof window === "undefined") return { left: menu.x, top: menu.y }; const width = 224; const estimatedHeight = menu.type === "node" ? Math.min(360, window.innerHeight - 72) : menu.type === "canvas" ? 250 : 84; return { left: clamp(menu.x, 12, Math.max(12, window.innerWidth - width - 12)), top: clamp(menu.y, 68, Math.max(68, window.innerHeight - estimatedHeight - 12)), }; } function getSubmenuLeft(parentLeft: number) { if (typeof window === "undefined") return parentLeft + 192; return parentLeft + 224 + 8 + 260 <= window.innerWidth - 12 ? parentLeft + 232 : Math.max(12, parentLeft - 268); } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function nodeTypeLabel(node?: CanvasNodeData | null) { if (!node) return "节点"; if (node.type === CanvasNodeType.Image) return "图片节点"; if (node.type === CanvasNodeType.Text) return "文本节点"; if (node.type === CanvasNodeType.Script) return "分镜脚本节点"; if (node.type === CanvasNodeType.Skill) return "技能节点"; if (node.type === CanvasNodeType.Video) return "视频节点"; if (node.type === CanvasNodeType.Audio) return "音频节点"; if (node.type === CanvasNodeType.Drawing) return "绘图节点"; if (node.type === CanvasNodeType.Frame) return isCanvasFolderNode(node) ? "文件夹" : "背板"; return "生成配置节点"; }