import { AnimatePresence } from "motion/react"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { ArrowLeft, AtSign, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, 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 { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category"; 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 { useActiveTheme } from "@/stores/canvas/use-canvas-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 }> = ASSET_CATEGORY_OPTIONS; type CanvasNodeContextMenuProps = { menu: ContextMenuState; node?: CanvasNodeData | null; workspaceMode?: CanvasWorkspaceMode; isProjectLinked?: boolean; canUndo: boolean; canRedo: boolean; canPaste: boolean; selectedCount?: number; 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; onCreateGenerationCopy: () => 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; onSpreadSelection?: () => void; onCopySelection?: () => void; onDeleteSelection?: () => void; onSendToAgent?: () => void; }; export function CanvasNodeContextMenu({ menu, node, workspaceMode = "professional", isProjectLinked = false, canUndo, canRedo, canPaste, selectedCount = 0, onClose, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters, onUndo, onRedo, onPaste, onCopyNode, onCreateGenerationCopy, onDuplicate, onDelete, onSaveAsset, onViewMedia, onEditText, onOpenDrawing, onGenerateImage, onCopyContent, onCopyMediaUrl, onUploadToArkPrivateAsset, onSetAssetCategory, onToggleFrame, onSpreadSelection, onCopySelection, onDeleteSelection, onSendToAgent, }: CanvasNodeContextMenuProps) { const theme = canvasThemes[useActiveTheme()]; 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(menu.type === "canvas" && Boolean(menu.createOpen)); setCategoryOpen(false); }, [menu]); 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 hasMultiSelection = selectedCount >= 2; const position = getContextMenuPosition(menu, hasMultiSelection); 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" ? ( <> {hasMultiSelection ? ( <> } label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} /> ) : null} } 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" && hasMultiSelection ? ( <> } label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} /> } label={`复制 ${selectedCount} 个节点`} shortcut="⌘C" disabled={!onCopySelection} onClick={() => onCopySelection && runAction(onCopySelection)} /> } label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } label={`删除 ${selectedCount} 个节点`} danger disabled={!onDeleteSelection} onClick={() => onDeleteSelection && runAction(onDeleteSelection)} /> ) : menu.type === "node" ? ( <> {isCharacterReference ? ( <> } label="查看角色详情" onClick={() => runAction(onEditText)} /> } label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } 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="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isImage ? } label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} /> } label="创建生成副本" onClick={() => runAction(onCreateGenerationCopy)} /> } 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="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } 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, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { const theme = canvasThemes[useActiveTheme()]; 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 ( event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} > ); } function MenuHeader({ title, description, onBack }: { title: string; description?: string; onBack?: () => void }) { const theme = canvasThemes[useActiveTheme()]; 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[useActiveTheme()]; const color = danger ? theme.accent.danger : theme.node.text; return ( ); } function MenuDivider() { const theme = canvasThemes[useActiveTheme()]; return
; } function getContextMenuPosition(menu: ContextMenuState, hasMultiSelection = false) { if (typeof window === "undefined") return { left: menu.x, top: menu.y }; const width = 224; const estimatedHeight = menu.type === "node" ? Math.min(hasMultiSelection ? 240 : 360, window.innerHeight - 72) : menu.type === "canvas" ? (hasMultiSelection ? 300 : 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 + 360 <= window.innerWidth - 12 ? parentLeft + 232 : Math.max(12, parentLeft - 368); } 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 "生成配置节点"; }