Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-asset-tray.tsx
QAyong 5b7f173102 feat: 完善创作工作台与用户诊断支持 (#295)
* feat(canvas): 参考素材 - 增加移除按钮并删除对应连线

* feat(canvas): 完善节点面板交互与拖拽反馈

* fix(canvas): 画布交互与小地图 - 统一浮层层级并完善节点识别

* feat(prompt-optimizer): 增加 AI 提示词优化器及宿主接入

* feat(canvas): 画布 - 增加自动整理节点按钮并补充文档

* chore(web): 前端 - 修正提示词优化器格式

* chore(backend): 后端 - 修正 Go 格式检查

* feat(workspace): 完善诊断导出与本地排障支持

* chore(web): 格式化诊断导出文件

* fix(web-test): 兼容 Bun 测试运行时

* fix(web-test): 对齐模型目录协议断言
2026-08-25 13:49:15 +08:00

309 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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} 项`,
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
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} 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[useThemeStore((state) => state.theme)];
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} 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 || "图片节点";
}