新增 ZIP 打包导出、精灵选择高亮、点击拾取等交互功能 - 新增 JSZip 依赖,支持一键打包下载图集图片和元数据文件 - CanvasPreview 新增精灵选择功能,支持点击/多选选择,带脉冲动画高亮效果 - 新增图片缓存机制,优化重绘性能 - FileListPanel 新增选中项自动滚动到可视区域 - 优化防抖延迟和加载状态视觉效果 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|