"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 { 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(null); const folderInputRef = useRef(null); const dropZoneRef = useRef(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 => { const allEntries: FileSystemEntry[] = []; const readBatch = (): Promise => { 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 => { 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[] = []; 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) => { 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 (
{/* Header */}
{folderName ? ( ) : ( )} {folderName || t("tools.textureAtlas.sprites") || "精灵"} {sprites.length > 0 && ( ({sprites.length}) )}
{sprites.length > 0 && ( )}
{/* File list */} {isExpanded && (
{sprites.length > 0 ? (
{sprites.map((sprite, index) => ( 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 */}
{ 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" />
{/* Info */}

{sprite.name}

{sprite.width} × {sprite.height}

{/* Remove button */}
))}
) : ( /* Empty state / Drop zone */
{isDragging ? (

{t("uploader.dropActive") || "释放文件即可上传"}

) : isLoading ? (

{t("common.loading") || "加载中..."}

) : ( <>

{t("atlas.dropSprites") || "拖拽精灵图到这里"}

{t("atlas.supportFolder") || "支持拖拽文件夹上传"}

)}
)}
)} {/* Hidden file inputs */} {/* Drag overlay */} {isDragging && (

{t("uploader.dropActive") || "释放文件即可上传"}

)}
); }