import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Crosshair, FolderOpen, ImageIcon, Images, PanelLeftClose, Plus, Search, X } from "lucide-react"; import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; import { canvasThemes } from "@/lib/canvas-theme"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { cn } from "@/lib/utils"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ImageAsset } from "@/stores/use-asset-store"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; export const CANVAS_IMAGE_ASSET_DND_TYPE = "application/x-infinite-canvas-image-asset"; type TrayTab = "library" | "canvas"; const TRAY_DEFAULT_HEIGHT = 520; const TRAY_MIN_HEIGHT = 400; const TRAY_BOTTOM_SAFE_SPACE = 82; type ResizeSession = { pointerId: number; startY: number; startHeight: number; nextHeight: number; frameId: number | null; }; function getMaxTrayHeight() { if (typeof window === "undefined") return 520; return Math.max(360, window.innerHeight - TRAY_BOTTOM_SAFE_SPACE); } function clampTrayHeight(height: number) { const maxHeight = getMaxTrayHeight(); const minHeight = Math.min(TRAY_MIN_HEIGHT, maxHeight); return Math.min(Math.max(height, minHeight), maxHeight); } type CanvasAssetTrayProps = { assetImages: ImageAsset[]; canvasImages: CanvasNodeData[]; showLibrary?: boolean; activeNodeId?: string | null; onInsertAssetImage: (asset: ImageAsset) => void; onFocusCanvasImage: (nodeId: string) => void; }; export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true, activeNodeId, onInsertAssetImage, onFocusCanvasImage }: CanvasAssetTrayProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const reducedMotion = useReducedMotion(); const { bringToFront, zIndex } = useCanvasOverlayLayer("asset-tray", "var(--z-panel)"); const rootRef = useRef(null); const [open, setOpen] = useState(false); const [tab, setTab] = useState(() => showLibrary ? "library" : "canvas"); const [keyword, setKeyword] = useState(""); const [trayHeight, setTrayHeight] = useState(() => clampTrayHeight(TRAY_DEFAULT_HEIGHT)); const panelRef = useRef(null); const resizeRef = useRef(null); const resizeCleanupRef = useRef<(() => void) | null>(null); const resizeHandleRef = useRef<{ element: HTMLButtonElement; pointerId: number } | null>(null); const query = keyword.trim().toLowerCase(); const filteredAssets = useMemo(() => assetImages.filter((asset) => !query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)), [assetImages, query]); const filteredNodes = useMemo(() => canvasImages.filter((node) => !query || canvasImageTitle(node).toLowerCase().includes(query)), [canvasImages, query]); const activeItems = showLibrary && tab === "library" ? filteredAssets : filteredNodes; const safeTrayHeight = clampTrayHeight(trayHeight); const motionEnabled = !reducedMotion; const startAssetDrag = (event: DragEvent, asset: ImageAsset) => { event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData(CANVAS_IMAGE_ASSET_DND_TYPE, asset.id); event.dataTransfer.setData("text/plain", asset.title); }; const paintResize = useCallback(() => { const session = resizeRef.current; if (!session || session.frameId !== null) return; session.frameId = window.requestAnimationFrame(() => { session.frameId = null; if (resizeRef.current !== session || !panelRef.current) return; panelRef.current.style.height = String(session.nextHeight) + "px"; }); }, []); const stopResize = useCallback(() => { const session = resizeRef.current; if (!session) return; resizeCleanupRef.current?.(); resizeCleanupRef.current = null; if (session.frameId !== null) { window.cancelAnimationFrame(session.frameId); session.frameId = null; } const finalHeight = session.nextHeight; if (panelRef.current) { panelRef.current.style.height = String(finalHeight) + "px"; panelRef.current.classList.remove("is-resizing"); } const handle = resizeHandleRef.current; if (handle?.element.hasPointerCapture(handle.pointerId)) handle.element.releasePointerCapture(handle.pointerId); resizeHandleRef.current = null; resizeRef.current = null; setTrayHeight(finalHeight); }, []); const startResize = useCallback((event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); stopResize(); const session: ResizeSession = { pointerId: event.pointerId, startY: event.clientY, startHeight: safeTrayHeight, nextHeight: safeTrayHeight, frameId: null }; const updateHeight = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== session.pointerId) return; session.nextHeight = clampTrayHeight(session.startHeight + session.startY - moveEvent.clientY); paintResize(); }; const finish = (endEvent: PointerEvent) => { if (endEvent.pointerId === session.pointerId) stopResize(); }; resizeRef.current = session; resizeHandleRef.current = { element: event.currentTarget, pointerId: event.pointerId }; panelRef.current?.classList.add("is-resizing"); event.currentTarget.setPointerCapture(event.pointerId); window.addEventListener("pointermove", updateHeight); window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", finish); resizeCleanupRef.current = () => { window.removeEventListener("pointermove", updateHeight); window.removeEventListener("pointerup", finish); window.removeEventListener("pointercancel", finish); }; }, [paintResize, safeTrayHeight, stopResize]); useEffect(() => () => { resizeCleanupRef.current?.(); const session = resizeRef.current; if (session && session.frameId !== null) window.cancelAnimationFrame(session.frameId); resizeRef.current = null; }, []); useEffect(() => { const syncHeight = () => setTrayHeight((height) => clampTrayHeight(height)); window.addEventListener("resize", syncHeight); return () => window.removeEventListener("resize", syncHeight); }, []); useEffect(() => { if (!showLibrary && tab === "library") setTab("canvas"); }, [showLibrary, tab]); useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("keydown", closeOnEscape); }; }, [open]); const dockItems: FloatingDockEntry[] = [ { id: "asset-tray-toggle", label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`, icon: {(showLibrary ? assetImages.length : 0) + canvasImages.length}, active: open, onClick: () => { bringToFront(); setOpen((value) => !value); }, }, ]; return (
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}> {open ? (
素材空间 拖入画布,或定位已经使用的图片
setOpen(false)} aria-label="收起素材空间">
{showLibrary ? setTab("library")} /> : null} setTab("canvas")} />
{showLibrary && tab === "library" ? ( filteredAssets.length ? (
{filteredAssets.map((asset) => ( startAssetDrag(event, asset)} onClick={() => onInsertAssetImage(asset)} icon={} /> ))}
) : ( ) ) : filteredNodes.length ? (
{filteredNodes.map((node) => ( onFocusCanvasImage(node.id)} icon={} /> ))}
) : ( )}
{showLibrary && tab === "library" ? "点击插入 · 拖拽定位" : "点击回到节点"} {activeItems.length} 项
) : null}
); } type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; function TrayTabButton({ active, label, theme, onClick }: { active: boolean; label: string; theme: CanvasTheme; onClick: () => void }) { return ( ); } function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, draggable = false, motionEnabled, onClick, onDragStart }: { title: string; imageUrl: string; storageKey?: string; icon: ReactNode; active?: boolean; draggable?: boolean; motionEnabled: boolean; onClick: () => void; onDragStart?: (event: DragEvent) => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const location = resourceStorageLocation(storageKey); return ( {imageUrl || storageKey ? } /> : } {title} {active ? "当前已选择" : draggable ? "拖入画布或点击插入" : "点击定位到画布"} {resourceStorageLabel(storageKey)} {icon} ); } function TrayEmpty({ text, theme }: { text: string; theme: CanvasTheme }) { return (
{text}
); } function canvasImageTitle(node: CanvasNodeData) { if (node.type !== CanvasNodeType.Image) return node.title; return node.title || node.metadata?.prompt || "图片节点"; }