Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-asset-tray.tsx
T

311 lines
19 KiB
TypeScript

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 { useActiveTheme } from "@/stores/canvas/use-canvas-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-yingce-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[useActiveTheme()];
const reducedMotion = useReducedMotion();
const { bringToFront, zIndex } = useCanvasOverlayLayer("asset-tray", "var(--z-panel)");
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<TrayTab>(() => showLibrary ? "library" : "canvas");
const [keyword, setKeyword] = useState("");
const [trayHeight, setTrayHeight] = useState(() => clampTrayHeight(TRAY_DEFAULT_HEIGHT));
const panelRef = useRef<HTMLElement>(null);
const resizeRef = useRef<ResizeSession | null>(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<HTMLElement>, 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<HTMLButtonElement>) => {
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} 项`,
displayLabel: "素材",
badge: (showLibrary ? assetImages.length : 0) + canvasImages.length,
icon: <Images />,
active: open,
onClick: () => {
bringToFront();
setOpen((value) => !value);
},
},
];
return (
<div ref={rootRef} data-canvas-no-zoom className="relative" style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<AnimatePresence>
{open ? (
<motion.aside
initial={{ opacity: 0, y: 20, scale: 0.92, rotateX: 5 }}
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
exit={{ opacity: 0, y: 14, scale: 0.95, rotateX: 3 }}
transition={aceternityMotion.spring.panel}
ref={panelRef}
className="canvas-asset-tray-panel aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] p-2.5 backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, color: theme.node.text, height: safeTrayHeight, minHeight: Math.min(TRAY_MIN_HEIGHT, getMaxTrayHeight()), maxHeight: "calc(100vh - 6rem)", boxShadow: `0 32px 100px ${theme.spatial.shadow}` }}
>
<button type="button" className="canvas-asset-tray-resize-handle absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
<span className="h-1 w-12 rounded-full bg-current" />
</button>
<div className="flex items-center justify-between gap-2 px-1 pb-2.5 pt-1.5">
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)]" style={{ background: theme.spatial.surface, color: theme.accent.primary }}>
<FolderOpen className="size-3.5" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold">素材空间</span>
<span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>拖入画布,或定位已经使用的图片</span>
</span>
</div>
<motion.button type="button" whileHover={motionEnabled ? { rotate: -5, scale: 1.05 } : undefined} whileTap={motionEnabled ? { scale: 0.92 } : undefined} className="grid size-7 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2" style={{ background: theme.spatial.surface, color: theme.node.muted }} onClick={() => setOpen(false)} aria-label="收起素材空间">
<PanelLeftClose className="size-3" />
</motion.button>
</div>
<div className={cn("relative grid gap-1 rounded-[var(--r-lg)] p-0.5", showLibrary ? "grid-cols-2" : "grid-cols-1")} style={{ background: theme.spatial.surface }}>
{showLibrary ? <TrayTabButton active={tab === "library"} label={`素材库 ${assetImages.length}`} theme={theme} onClick={() => setTab("library")} /> : null}
<TrayTabButton active={tab === "canvas"} label={`当前画布 ${canvasImages.length}`} theme={theme} onClick={() => setTab("canvas")} />
</div>
<label className="mt-2 flex h-8 items-center gap-1.5 rounded-[11px] px-2.5 focus-within:ring-2" style={{ background: theme.spatial.surface }}>
<Search className="size-3.5 shrink-0" style={{ color: theme.node.muted }} />
<input type="search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索图片素材..." className="min-w-0 flex-1 bg-transparent text-[var(--fs-tiny)] outline-none placeholder:opacity-55" aria-label="搜索图片素材" />
{keyword ? <button type="button" className="grid size-6 shrink-0 place-items-center rounded-full opacity-55 hover:opacity-100" onClick={() => setKeyword("")} aria-label="清空搜索"><X className="size-3" /></button> : null}
</label>
<div className="thin-scrollbar mt-2.5 min-h-0 flex-1 overflow-y-auto pr-1">
{showLibrary && tab === "library" ? (
filteredAssets.length ? (
<div className="space-y-1.5">
{filteredAssets.map((asset) => (
<AssetTrayRow key={asset.id} title={asset.title} imageUrl={asset.coverUrl || asset.data.dataUrl} storageKey={asset.data.storageKey} draggable motionEnabled={motionEnabled} onDragStart={(event) => startAssetDrag(event, asset)} onClick={() => onInsertAssetImage(asset)} icon={<Plus className="size-3.5" />} />
))}
</div>
) : (
<TrayEmpty text="没有匹配的图片素材" theme={theme} />
)
) : filteredNodes.length ? (
<div className="space-y-1.5">
{filteredNodes.map((node) => (
<AssetTrayRow key={node.id} title={canvasImageTitle(node)} imageUrl={node.metadata?.content || ""} storageKey={node.metadata?.storageKey} active={activeNodeId === node.id} motionEnabled={motionEnabled} onClick={() => onFocusCanvasImage(node.id)} icon={<Crosshair className="size-3.5" />} />
))}
</div>
) : (
<TrayEmpty text="当前画布没有匹配图片" theme={theme} />
)}
</div>
<div className="flex items-center justify-between px-1 pt-2.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<span>{showLibrary && tab === "library" ? "点击插入 · 拖拽定位" : "点击回到节点"}</span>
<span className="rounded-full px-2 py-0.5 tabular-nums" style={{ background: theme.spatial.surface }}>{activeItems.length} 项</span>
</div>
</motion.aside>
) : null}
</AnimatePresence>
<FloatingDock items={dockItems} showLabels className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
</div>
);
}
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
function TrayTabButton({ active, label, theme, onClick }: { active: boolean; label: string; theme: CanvasTheme; onClick: () => void }) {
return (
<button type="button" className={cn("relative z-10 h-7 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-tiny)] font-semibold outline-none transition-colors focus-visible:ring-2", active ? "" : "opacity-55 hover:opacity-90")} style={{ color: active ? theme.node.text : theme.node.muted }} onClick={onClick}>
{active ? <motion.span layoutId="canvas-asset-tray-active-tab" className="absolute inset-0 -z-10 rounded-[var(--dock-item-radius)]" style={{ background: theme.node.panel, boxShadow: `0 6px 16px ${theme.spatial.shadow}` }} transition={aceternityMotion.spring.dock} /> : null}
{label}
</button>
);
}
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<HTMLElement>) => void }) {
const theme = canvasThemes[useActiveTheme()];
const location = resourceStorageLocation(storageKey);
return (
<motion.button
type="button"
draggable={draggable}
whileHover={motionEnabled ? { x: 4, scale: 1.008 } : undefined}
whileTap={motionEnabled ? { scale: 0.985 } : undefined}
transition={aceternityMotion.spring.dock}
className="group grid h-12 w-full grid-cols-[36px_minmax(0,1fr)_auto_24px] items-center gap-1.5 rounded-[var(--r-lg)] px-1.5 text-left outline-none focus-visible:ring-2"
style={{ background: active ? theme.accent.primarySoft : theme.spatial.surface, color: active ? theme.accent.primary : theme.node.text, boxShadow: active ? `0 6px 18px ${theme.spatial.shadow}` : undefined }}
onClick={onClick}
onDragStartCapture={onDragStart}
>
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--dock-item-radius)]" style={{ background: theme.node.fill }}>
{imageUrl || storageKey ? <CachedResourceImage storageKey={storageKey} src={imageUrl} variant="thumbnail" alt="" width={36} height={36} className="size-full object-cover" draggable={false} fallback={<ImageIcon className="size-3.5 opacity-55" />} /> : <ImageIcon className="size-3.5 opacity-55" />}
</span>
<span className="min-w-0">
<span className="block truncate text-[var(--fs-tiny)] font-semibold">{title}</span>
<span className="mt-0.5 block text-[var(--fs-micro)] opacity-45">{active ? "当前已选择" : draggable ? "拖入画布或点击插入" : "点击定位到画布"}</span>
</span>
<span className={cn("rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold", location === "oss" ? "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300" : location === "local" ? "bg-amber-500/12 text-amber-700 dark:text-amber-300" : "bg-black/5 text-stone-400 dark:bg-white/8 dark:text-stone-500")} title={resourceStorageTitle(storageKey)}>
{resourceStorageLabel(storageKey)}
</span>
<span className="grid size-6 place-items-center rounded-full opacity-45 transition-opacity group-hover:opacity-90" style={{ background: theme.node.panel }}>{icon}</span>
</motion.button>
);
}
function TrayEmpty({ text, theme }: { text: string; theme: CanvasTheme }) {
return (
<div className="grid h-full min-h-[200px] place-items-center rounded-[var(--dock-radius)] text-center" style={{ background: theme.spatial.surface, color: theme.node.muted }}>
<span><Images className="mx-auto size-5 opacity-35" /><span className="mt-2 block text-xs opacity-55">{text}</span></span>
</div>
);
}
function canvasImageTitle(node: CanvasNodeData) {
if (node.type !== CanvasNodeType.Image) return node.title;
return node.title || node.metadata?.prompt || "图片节点";
}