Files
mini-game-ai/src/components/tools/atlas/FileListPanel.tsx
richarjiang c26d6eaada feat: 增强纹理图集工具交互体验
新增 ZIP 打包导出、精灵选择高亮、点击拾取等交互功能

- 新增 JSZip 依赖,支持一键打包下载图集图片和元数据文件
- CanvasPreview 新增精灵选择功能,支持点击/多选选择,带脉冲动画高亮效果
- 新增图片缓存机制,优化重绘性能
- FileListPanel 新增选中项自动滚动到可视区域
- 优化防抖延迟和加载状态视觉效果

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 22:16:22 +08:00

521 lines
18 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.

"use client";
import { useCallback, useRef, useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Folder,
FolderOpen,
Image as ImageIcon,
Trash2,
Upload,
X,
ChevronDown,
ChevronRight
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAtlasStore, type BrowserSprite } from "@/store/atlasStore";
import { useSafeTranslation } from "@/lib/i18n";
/**
* Generate unique ID
*/
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Natural sort comparator for filenames
*/
function naturalSort(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}
/**
* Check if file is an image
*/
function isImageFile(file: File): boolean {
const imageTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "image/bmp"];
return imageTypes.includes(file.type) || /\.(png|jpe?g|webp|gif|bmp)$/i.test(file.name);
}
/**
* Load image file as BrowserSprite
*/
async function loadImageAsSprite(file: File): Promise<BrowserSprite> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const blob = new Blob([reader.result as ArrayBuffer], { type: file.type });
const imageBitmap = await createImageBitmap(blob);
resolve({
id: generateId(),
name: file.name,
width: imageBitmap.width,
height: imageBitmap.height,
image: imageBitmap,
file,
});
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsArrayBuffer(file);
});
}
export function FileListPanel() {
const { t } = useSafeTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(true);
const {
sprites,
folderName,
addSprites,
removeSprite,
clearSprites,
setFolderName,
selectedSpriteIds,
selectSprite,
setStatus,
} = useAtlasStore();
/**
* Process uploaded files
*/
const processFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files);
const imageFiles = fileArray.filter(isImageFile);
if (imageFiles.length === 0) return;
setIsLoading(true);
setStatus("loading");
try {
// Sort by filename
imageFiles.sort((a, b) => naturalSort(a.name, b.name));
// Extract folder name from first file's path
const firstFile = imageFiles[0];
if (firstFile.webkitRelativePath) {
const pathParts = firstFile.webkitRelativePath.split("/");
if (pathParts.length > 1) {
setFolderName(pathParts[0]);
}
}
// Load all images
const loadPromises = imageFiles.map((file) => loadImageAsSprite(file));
const loadedSprites = await Promise.all(loadPromises);
addSprites(loadedSprites);
setStatus("idle");
} catch (error) {
console.error("Failed to load images:", error);
setStatus("error");
} finally {
setIsLoading(false);
}
}, [addSprites, setFolderName, setStatus]);
/**
* Handle drag events
*/
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if we're leaving the drop zone
if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const items = e.dataTransfer.items;
const dataTransferFiles = e.dataTransfer.files;
const files: File[] = [];
// Read all entries from a directory (handles batched readEntries)
const readAllEntries = async (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> => {
const allEntries: FileSystemEntry[] = [];
const readBatch = (): Promise<FileSystemEntry[]> => {
return new Promise((resolve) => {
reader.readEntries((entries) => resolve(entries));
});
};
// readEntries may return results in batches, keep reading until empty
let batch = await readBatch();
while (batch.length > 0) {
allEntries.push(...batch);
batch = await readBatch();
}
return allEntries;
};
// Handle folder drop
const processEntry = async (entry: FileSystemEntry): Promise<void> => {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
return new Promise((resolve) => {
fileEntry.file((file) => {
if (isImageFile(file)) {
// Add webkitRelativePath-like info
Object.defineProperty(file, "webkitRelativePath", {
value: entry.fullPath.substring(1), // Remove leading slash
writable: false,
});
files.push(file);
}
resolve();
});
});
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const reader = dirEntry.createReader();
const entries = await readAllEntries(reader);
for (const childEntry of entries) {
await processEntry(childEntry);
}
}
};
// Check if webkitGetAsEntry is supported
let hasEntrySupport = false;
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === "function") {
hasEntrySupport = true;
}
if (hasEntrySupport) {
// Process using FileSystem API (supports folders)
const entryPromises: Promise<void>[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const entry = item.webkitGetAsEntry?.();
if (entry) {
entryPromises.push(processEntry(entry));
}
}
await Promise.all(entryPromises);
} else {
// Fallback: use dataTransfer.files directly (no folder support)
for (let i = 0; i < dataTransferFiles.length; i++) {
const file = dataTransferFiles[i];
if (file && isImageFile(file)) {
files.push(file);
}
}
}
if (files.length > 0) {
// Extract folder name from first file
if (files[0].webkitRelativePath) {
const pathParts = files[0].webkitRelativePath.split("/");
if (pathParts.length > 1) {
setFolderName(pathParts[0]);
}
}
await processFiles(files);
}
}, [processFiles, setFolderName]);
/**
* Handle file input change
*/
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
processFiles(e.target.files);
}
// Reset input
e.target.value = "";
}, [processFiles]);
/**
* Handle sprite click
*/
const handleSpriteClick = useCallback((id: string, e: React.MouseEvent) => {
selectSprite(id, e.ctrlKey || e.metaKey);
}, [selectSprite]);
/**
* Handle clear all
*/
const handleClear = useCallback(() => {
clearSprites();
setFolderName("");
}, [clearSprites, setFolderName]);
/**
* Scroll selected item into view
*/
useEffect(() => {
if (selectedSpriteIds.length === 1) {
const selectedId = selectedSpriteIds[0];
const element = document.getElementById(`sprite-${selectedId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
}, [selectedSpriteIds]);
return (
<div
ref={dropZoneRef}
className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{folderName ? (
<FolderOpen className="h-4 w-4 text-primary" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium truncate max-w-[120px]">
{folderName || t("tools.textureAtlas.sprites") || "精灵"}
</span>
{sprites.length > 0 && (
<span className="text-xs text-muted-foreground">
({sprites.length})
</span>
)}
</div>
{sprites.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-white/[0.06]"
onClick={handleClear}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{/* File list */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: "auto" }}
exit={{ height: 0 }}
className="flex-1 overflow-hidden"
>
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{sprites.length > 0 ? (
<div className="space-y-1 p-2">
{sprites.map((sprite, index) => (
<motion.div
key={sprite.id}
id={`sprite-${sprite.id}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ delay: index * 0.02 }}
onClick={(e) => handleSpriteClick(sprite.id, e)}
className={`
group flex items-center gap-2.5 rounded-xl px-2.5 py-2 cursor-pointer
transition-all duration-150 hover:bg-white/[0.06]
${selectedSpriteIds.includes(sprite.id) ? "bg-primary/15 ring-1 ring-primary/30" : ""}
`}
>
{/* Thumbnail */}
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-lg border border-white/[0.08] bg-black/30">
<canvas
ref={(canvas) => {
if (canvas && sprite.image) {
const ctx = canvas.getContext("2d");
if (ctx) {
canvas.width = 36;
canvas.height = 36;
// Calculate aspect ratio fit
const scale = Math.min(36 / sprite.width, 36 / sprite.height);
const w = sprite.width * scale;
const h = sprite.height * scale;
const x = (36 - w) / 2;
const y = (36 - h) / 2;
ctx.clearRect(0, 0, 36, 36);
ctx.drawImage(sprite.image, x, y, w, h);
}
}
}}
className="h-full w-full"
/>
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{sprite.name}</p>
<p className="text-[10px] text-muted-foreground/70">
{sprite.width} × {sprite.height}
</p>
</div>
{/* Remove button */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:bg-white/[0.08] transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeSprite(sprite.id);
}}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
) : (
/* Empty state / Drop zone */
<div className="flex h-full min-h-[200px] flex-col items-center justify-center p-4 text-center">
{isDragging ? (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center"
>
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/20">
<Upload className="h-7 w-7 text-primary" />
</div>
<p className="text-sm font-medium text-primary">
{t("uploader.dropActive") || "释放文件即可上传"}
</p>
</motion.div>
) : isLoading ? (
<div className="flex flex-col items-center">
<div className="mb-3 h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs text-muted-foreground">
{t("common.loading") || "加载中..."}
</p>
</div>
) : (
<>
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-white/[0.04]">
<ImageIcon className="h-7 w-7 text-muted-foreground/60" />
</div>
<p className="mb-1 text-sm font-medium">
{t("atlas.dropSprites") || "拖拽精灵图到这里"}
</p>
<p className="mb-4 text-xs text-muted-foreground/60">
{t("atlas.supportFolder") || "支持拖拽文件夹上传"}
</p>
<div className="flex flex-col gap-2 w-full px-2">
<Button
variant="outline"
size="sm"
className="w-full rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="mr-1.5 h-3.5 w-3.5" />
{t("atlas.selectFiles") || "选择文件"}
</Button>
<Button
variant="outline"
size="sm"
className="w-full rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={() => folderInputRef.current?.click()}
>
<Folder className="mr-1.5 h-3.5 w-3.5" />
{t("atlas.selectFolder") || "选择文件夹"}
</Button>
</div>
</>
)}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
className="hidden"
onChange={handleFileChange}
/>
<input
ref={folderInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
// @ts-expect-error - webkitdirectory is not in the type definition
webkitdirectory="true"
className="hidden"
onChange={handleFileChange}
/>
{/* Drag overlay */}
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 flex items-center justify-center bg-primary/10 backdrop-blur-sm"
>
<div className="rounded-lg border-2 border-dashed border-primary bg-background/80 px-8 py-6 text-center">
<Upload className="mx-auto mb-2 h-8 w-8 text-primary" />
<p className="font-medium text-primary">
{t("uploader.dropActive") || "释放文件即可上传"}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}