import { memo, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react"; import { motion } from "motion/react"; import { useTranslation } from "react-i18next"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { exportCanvasNodes } from "@/lib/canvas/canvas-export"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { cn } from "@/lib/utils"; import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog"; import { fetchSourcePrompts, type Prompt } from "@/services/api/prompts"; import { uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store"; import { usePromptSourceStore } from "@/stores/use-prompt-source-store"; import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import type { InsertAssetPayload } from "./asset-picker-modal"; const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000; const PANEL_EASE = [0.22, 1, 0.36, 1] as const; type PanelTab = "canvas" | "assets" | "prompts"; type Props = { nodes: CanvasNodeData[]; selectedNodeIds: Set; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; onInsertAsset: (payload: InsertAssetPayload) => void; }; const NODE_TYPE_ICON: Record = { [CanvasNodeType.Image]: ImageIcon, [CanvasNodeType.Video]: Video, [CanvasNodeType.Audio]: Music2, [CanvasNodeType.Text]: Type, [CanvasNodeType.Config]: Settings2, [CanvasNodeType.Group]: Square, }; const STATUS_COLOR: Record = { success: "#22c55e", loading: "#f59e0b", error: "#ef4444", idle: "transparent", }; export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, onInsertAsset }: Props) { const { t } = useTranslation(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [tab, setTab] = useState("canvas"); const width = useCanvasSidePanelStore((state) => state.width); const panelOpen = useCanvasSidePanelStore((state) => state.panelOpen); const panelMounted = useCanvasSidePanelStore((state) => state.panelMounted); const panelClosing = useCanvasSidePanelStore((state) => state.panelClosing); const setWidth = useCanvasSidePanelStore((state) => state.setWidth); const [resizing, setResizing] = useState(false); const startResize = (event: ReactPointerEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = width; let nextWidth = startWidth; const onMove = (moveEvent: PointerEvent) => { nextWidth = Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, startWidth + moveEvent.clientX - startX)); setWidth(nextWidth); }; const onUp = () => { localStorage.setItem("canvas-side-panel-width", String(nextWidth)); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); setResizing(false); }; setResizing(true); window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }; if (!panelMounted) return null; return (
setTab("canvas")} /> setTab("assets")} /> setTab("prompts")} />
{tab === "canvas" ? ( ) : tab === "assets" ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // Canvas tab: list nodes and center, zoom, and select the clicked node. // --------------------------------------------------------------------------- const NODE_FILTER_VALUES = ["all", CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Text, CanvasNodeType.Audio, CanvasNodeType.Config, CanvasNodeType.Group]; function nodePreviewText(node: CanvasNodeData) { if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || ""; return getNodeDefinition(node.type)?.title || node.type; } function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; theme: CanvasTheme }) { const { message } = App.useApp(); const { t } = useTranslation(); const [keyword, setKeyword] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [selectMode, setSelectMode] = useState(false); const [checked, setChecked] = useState>(new Set()); const [exporting, setExporting] = useState(false); const filtered = useMemo(() => { const query = keyword.trim().toLowerCase(); return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query))); }, [nodes, keyword, typeFilter]); const exitSelect = () => { setSelectMode(false); setChecked(new Set()); }; const toggleChecked = (id: string) => setChecked((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); const allChecked = filtered.length > 0 && filtered.every((node) => checked.has(node.id)); const toggleAll = () => setChecked(allChecked ? new Set() : new Set(filtered.map((node) => node.id))); const handleExport = async () => { const targets = nodes.filter((node) => checked.has(node.id)); if (!targets.length) return; setExporting(true); const hide = message.loading(t("canvas.sidePanel.exporting"), 0); try { await exportCanvasNodes(targets, t("canvas.sidePanel.exportName", { count: targets.length })); message.success(t("canvas.sidePanel.exported", { count: targets.length })); exitSelect(); } catch (error) { console.error(error); message.error(t("canvas.sidePanel.exportFailed")); } finally { hide(); setExporting(false); } }; return (
{t("canvas.sidePanel.elements")} {filtered.length ? {filtered.length} : null} {selectMode ? null : } placeholder={t("canvas.sidePanel.searchNodes")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{filtered.length ? (
{filtered.map((node) => { const Icon = NODE_TYPE_ICON[node.type] || FileText; const isImage = node.type === CanvasNodeType.Image && node.metadata?.content; const isChecked = checked.has(node.id); const active = selectMode ? isChecked : selectedNodeIds.has(node.id); return (
{selectMode || !isImage ? null : (
)}
); })}
) : (
{t("canvas.sidePanel.noNodes")}
)}
{selectMode ? (
{t("canvas.sidePanel.selected", { count: checked.size })}
) : null}
); } function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) { return ( {checked ? : null} ); } // --------------------------------------------------------------------------- // Assets tab: collapsible type groups, tag filtering, and click-to-insert. // --------------------------------------------------------------------------- const ASSET_GROUPS: { kind: AssetKind; icon: typeof Square }[] = [ { kind: "image", icon: ImageIcon }, { kind: "video", icon: Video }, { kind: "text", icon: FileText }, ]; function buildInsertPayload(asset: Asset): InsertAssetPayload { if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title }; if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height }; return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title }; } const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) { const { message } = App.useApp(); const { t } = useTranslation(); const assets = useAssetStore((state) => state.assets); const addAsset = useAssetStore((state) => state.addAsset); const removeAsset = useAssetStore((state) => state.removeAsset); const [keyword, setKeyword] = useState(""); const [tagFilter, setTagFilter] = useState("all"); const [collapsed, setCollapsed] = useState>({}); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]); const filtered = useMemo(() => { const query = keyword.trim().toLowerCase(); return assets.filter((asset) => (tagFilter === "all" || (asset.tags || []).includes(tagFilter)) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query))); }, [assets, keyword, tagFilter]); const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]); const handleFiles = async (fileList: FileList | null) => { const files = Array.from(fileList || []); if (!files.length) return; setUploading(true); const hide = message.loading(t("canvas.sidePanel.addingAssets"), 0); let added = 0; try { for (const file of files) { if (file.type.startsWith("image/")) { const image = await uploadImage(file); addAsset({ kind: "image", title: file.name || t("assets.kinds.image"), coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } }); added += 1; } else if (file.type.startsWith("video/")) { const media = await uploadMediaFile(file, "video"); addAsset({ kind: "video", title: file.name || t("assets.kinds.video"), coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } }); added += 1; } } if (added) message.success(t("canvas.sidePanel.addedAssets", { count: added })); else message.warning(t("canvas.sidePanel.mediaOnly")); } catch (error) { console.error(error); message.error(t("canvas.sidePanel.addFailed")); } finally { hide(); setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } }; return (
} placeholder={t("canvas.sidePanel.searchAssets")} value={keyword} onChange={(e) => setKeyword(e.target.value)} /> void handleFiles(e.target.files)} />
{allTags.length ? (
setTagFilter("all")}> {t("common.all")} {allTags.map((tag) => ( setTagFilter((prev) => (prev === tag ? "all" : tag))}> {tag} ))}
) : null}
{groups.length ? (
{groups.map((group) => { const isCollapsed = collapsed[group.kind]; return (
{isCollapsed ? null : (
{group.items.map((asset) => ( onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success(t("canvas.sidePanel.assetRemoved")))} /> ))}
)}
); })}
) : ( )}
); }); function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) { const { t } = useTranslation(); return (
); } function AssetCover({ asset }: { asset: Asset }) { if (asset.kind === "text") return
{asset.data.content}
; if (asset.kind === "video") { if (asset.coverUrl) return ; return