Manager
View Site
Name
Type
React (.jsx)
HTML (.html)
Icon
Description
Code Editor
Revision History (0)
import React, { useState, useEffect, useRef } from 'react'; import { Play, Pause, Plus, Trash2, Upload, Settings, Image as ImageIcon, Layers, ChevronDown, ChevronUp, RotateCcw, X, Download, Loader2, Move, ChevronLeft, ChevronRight, Crosshair, Eye, Grid, ZoomIn, MousePointer2 } from 'lucide-react'; // --- Utility: Parse frame string into array --- const parseFrameString = (str, maxFrames) => { if (!str) return []; const frames = []; const parts = str.split(',').map(s => s.trim()); parts.forEach(part => { if (part.includes('-')) { const [start, end] = part.split('-').map(n => parseInt(n, 10)); if (!isNaN(start) && !isNaN(end)) { const step = start < end ? 1 : -1; for (let i = start; i !== end + step; i += step) { if (i >= 0 && i < maxFrames) frames.push(i); } } } else { const num = parseInt(part, 10); if (!isNaN(num) && num >= 0 && num < maxFrames) { frames.push(num); } } }); return frames; }; const App = () => { // --- State --- const [imageSrc, setImageSrc] = useState(null); const [imageDimensions, setImageDimensions] = useState({ w: 0, h: 0 }); const [isDragging, setIsDragging] = useState(false); // Drag & Drop state // Config const [rowsInput, setRowsInput] = useState("6"); const [colsInput, setColsInput] = useState("6"); const [fps, setFps] = useState(12); // Animation State const [animations, setAnimations] = useState([ { id: 1, name: 'New Animation', frames: '' }, ]); const [selectedAnimId, setSelectedAnimId] = useState(1); const [isPlaying, setIsPlaying] = useState(true); const [showConfig, setShowConfig] = useState(false); // Preview Options const [previewScale, setPreviewScale] = useState(1); // 1 = 100% // Jitter/Offset State const [offsets, setOffsets] = useState({}); const [isJitterMode, setIsJitterMode] = useState(false); const [jitterFrameStep, setJitterFrameStep] = useState(0); // Ghost Options const [showGhostPrev, setShowGhostPrev] = useState(true); const [showGhostFirst, setShowGhostFirst] = useState(false); // Export State const [isExporting, setIsExporting] = useState(false); const [gifLibLoaded, setGifLibLoaded] = useState(false); // Refs const canvasRef = useRef(null); const requestRef = useRef(); const previousTimeRef = useRef(); // Helpers const rows = parseInt(rowsInput) || 1; const cols = parseInt(colsInput) || 1; const activeAnimation = animations.find(a => a.id === selectedAnimId) || animations[0]; const maxFrames = rows * cols; const frameList = parseFrameString(activeAnimation.frames, maxFrames); // --- Effects --- // Load gif.js useEffect(() => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js'; script.async = true; script.onload = () => setGifLibLoaded(true); document.body.appendChild(script); return () => { if (document.body.contains(script)) { document.body.removeChild(script); } }; }, []); // Animation / Render Loop useEffect(() => { const canvas = canvasRef.current; if (!canvas || !imageSrc) return; const ctx = canvas.getContext('2d'); const img = new Image(); img.src = imageSrc; let animationFrameIndex = 0; const render = (time) => { if (previousTimeRef.current != undefined) { const deltaTime = time - previousTimeRef.current; const interval = 1000 / fps; if (isJitterMode) { // --- JITTER MODE RENDER --- const frameWidth = imageDimensions.w / cols; const frameHeight = imageDimensions.h / rows; ctx.clearRect(0, 0, canvas.width, canvas.height); if (frameList.length > 0 && jitterFrameStep < frameList.length) { // Helper to draw a frame const drawF = (idx, opacity, composite = 'source-over') => { const frameNum = frameList[idx]; const offset = offsets[frameNum] || { x: 0, y: 0 }; const col = frameNum % cols; const row = Math.floor(frameNum / cols); ctx.save(); ctx.globalAlpha = opacity; ctx.globalCompositeOperation = composite; ctx.drawImage( img, col * frameWidth, row * frameHeight, frameWidth, frameHeight, 0 + offset.x, 0 + offset.y, canvas.width, canvas.height ); ctx.restore(); }; drawF(jitterFrameStep, 1.0); if (showGhostFirst && jitterFrameStep > 0) { drawF(0, 0.3, 'source-over'); } if (showGhostPrev && jitterFrameStep > 0) { drawF(jitterFrameStep - 1, 0.4, 'source-over'); } // Draw Crosshair ctx.strokeStyle = 'rgba(0, 255, 0, 0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(canvas.width/2, 0); ctx.lineTo(canvas.width/2, canvas.height); ctx.moveTo(0, canvas.height/2); ctx.lineTo(canvas.width, canvas.height/2); ctx.stroke(); } else { ctx.fillStyle = "#1e293b"; ctx.fillText("No Frame", 10, 10); } } else { // --- PLAYBACK MODE RENDER --- if (deltaTime > interval) { if (isPlaying && frameList.length > 0) { const currentFrame = frameList[animationFrameIndex]; const frameWidth = imageDimensions.w / cols; const frameHeight = imageDimensions.h / rows; const offset = offsets[currentFrame] || { x: 0, y: 0 }; const col = currentFrame % cols; const row = Math.floor(currentFrame / cols); const sx = col * frameWidth; const sy = row * frameHeight; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage( img, sx, sy, frameWidth, frameHeight, 0 + offset.x, 0 + offset.y, canvas.width, canvas.height ); animationFrameIndex = (animationFrameIndex + 1) % frameList.length; } previousTimeRef.current = time - (deltaTime % interval); } } } else { previousTimeRef.current = time; } requestRef.current = requestAnimationFrame(render); }; if (img.complete) { requestRef.current = requestAnimationFrame(render); } else { img.onload = () => { requestRef.current = requestAnimationFrame(render); } } return () => cancelAnimationFrame(requestRef.current); }, [imageSrc, rows, cols, activeAnimation, fps, isPlaying, imageDimensions, frameList, isJitterMode, jitterFrameStep, offsets, showGhostFirst, showGhostPrev]); // --- Handlers --- const handleFileProcess = (file) => { if (file && file.type.startsWith('image/')) { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { setImageDimensions({ w: img.width, h: img.height }); setImageSrc(url); setIsDragging(false); }; img.src = url; } else { setIsDragging(false); } }; const handleImageUpload = (e) => handleFileProcess(e.target.files[0]); // Drag and Drop Handlers const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e) => { e.preventDefault(); handleFileProcess(e.dataTransfer.files[0]); }; const updateAnimFrames = (newFramesStr) => { setAnimations(animations.map(a => a.id === selectedAnimId ? { ...a, frames: newFramesStr } : a)); }; const handleGridClick = (frameIndex) => { const currentFrames = activeAnimation.frames.trim(); const separator = currentFrames.length > 0 && !currentFrames.endsWith(',') ? ', ' : ''; const newFrames = `${currentFrames}${separator}${frameIndex}`; updateAnimFrames(newFrames); }; const handleAddAllFrames = () => { const allFrames = Array.from({length: rows * cols}, (_, i) => i).join('-'); // Use range syntax (0-63) which is cleaner updateAnimFrames(`0-${rows * cols - 1}`); }; const removeFrameAtIndex = (indexToRemove) => { const newFrames = frameList.filter((_, idx) => idx !== indexToRemove); updateAnimFrames(newFrames.join(', ')); if (jitterFrameStep >= newFrames.length) { setJitterFrameStep(Math.max(0, newFrames.length - 1)); } }; const handleClear = () => { if(window.confirm("Clear this sequence?")) { updateAnimFrames(''); setOffsets({}); } }; const handleNudge = (dx, dy) => { if (frameList.length === 0) return; const currentFrameIdx = frameList[jitterFrameStep]; const currentOffset = offsets[currentFrameIdx] || { x: 0, y: 0 }; setOffsets({ ...offsets, [currentFrameIdx]: { x: currentOffset.x + dx, y: currentOffset.y + dy } }); }; const handleExportGif = async () => { if (!imageSrc || frameList.length === 0 || !gifLibLoaded) return; setIsExporting(true); setIsPlaying(false); setIsJitterMode(false); try { const workerResponse = await fetch('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js'); const workerBlob = await workerResponse.blob(); const workerUrl = URL.createObjectURL(workerBlob); const frameWidth = imageDimensions.w / cols; const frameHeight = imageDimensions.h / rows; const gif = new window.GIF({ workers: 2, quality: 10, width: frameWidth, height: frameHeight, workerScript: workerUrl, transparent: 0x000000 }); const img = new Image(); img.src = imageSrc; await new Promise(resolve => { if(img.complete) resolve(); else img.onload = resolve; }); const tempCanvas = document.createElement('canvas'); tempCanvas.width = frameWidth; tempCanvas.height = frameHeight; const ctx = tempCanvas.getContext('2d'); const delay = 1000 / fps; frameList.forEach(frameIndex => { const col = frameIndex % cols; const row = Math.floor(frameIndex / cols); const sx = col * frameWidth; const sy = row * frameHeight; const offset = offsets[frameIndex] || { x: 0, y: 0 }; ctx.clearRect(0, 0, frameWidth, frameHeight); ctx.drawImage( img, sx, sy, frameWidth, frameHeight, 0 + offset.x, 0 + offset.y, frameWidth, frameHeight ); gif.addFrame(ctx, { copy: true, delay: delay }); }); gif.on('finished', (blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${activeAnimation.name || 'animation'}.gif`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setIsExporting(false); setIsPlaying(true); URL.revokeObjectURL(workerUrl); }); gif.render(); } catch (err) { console.error("GIF Export Error:", err); alert("Failed to export GIF."); setIsExporting(false); setIsPlaying(true); } }; useEffect(() => { const handleKeyDown = (e) => { if (!isJitterMode) return; if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) { e.preventDefault(); } if (e.key === 'ArrowUp') handleNudge(0, -1); if (e.key === 'ArrowDown') handleNudge(0, 1); if (e.key === 'ArrowLeft') handleNudge(-1, 0); if (e.key === 'ArrowRight') handleNudge(1, 0); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isJitterMode, jitterFrameStep, offsets, frameList]); return ( <div className="flex flex-col h-screen bg-slate-950 text-slate-100 font-sans overflow-hidden relative" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > {/* Drag Overlay */} {isDragging && ( <div className="absolute inset-0 z-50 bg-blue-600/20 border-4 border-blue-500 border-dashed backdrop-blur-sm flex items-center justify-center pointer-events-none"> <div className="bg-slate-900 p-8 rounded-xl shadow-2xl flex flex-col items-center text-blue-400"> <ImageIcon size={64} className="mb-4 animate-bounce" /> <h2 className="text-2xl font-bold">Drop Sprite Sheet Here</h2> </div> </div> )} {/* Navbar */} <header className="h-14 bg-slate-900 border-b border-slate-800 flex items-center justify-between px-4 shrink-0 z-20 gap-2"> <div className="flex items-center gap-2 overflow-hidden"> <Layers className="text-blue-500 shrink-0" size={20} /> <span className="font-bold text-sm md:text-lg tracking-tight truncate">SpriteAnimator</span> </div> <div className="flex items-center gap-2"> <button onClick={handleExportGif} disabled={!imageSrc || isExporting || !gifLibLoaded || frameList.length === 0} className={` flex items-center gap-2 px-3 py-1.5 rounded-md text-xs md:text-sm font-medium transition-colors ${!imageSrc || frameList.length === 0 ? 'bg-slate-800 text-slate-500 cursor-not-allowed' : 'bg-emerald-600 hover:bg-emerald-500 text-white'} `} > {isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />} <span className="hidden md:inline">Export GIF</span> <span className="md:hidden">GIF</span> </button> <label className="flex items-center gap-2 cursor-pointer bg-blue-600 active:bg-blue-700 text-white px-3 py-1.5 rounded-md text-xs md:text-sm font-medium transition-colors"> <Upload size={14} /> <span className="hidden md:inline">Upload</span> <input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} /> </label> </div> </header> <div className="flex-1 flex flex-col md:flex-row overflow-hidden"> {/* CONTROLS SIDEBAR */} {!isJitterMode && ( <aside className={` bg-slate-900 border-r border-slate-800 flex-col shrink-0 z-10 transition-all ${showConfig ? 'h-auto max-h-[40vh]' : 'h-0 md:h-auto'} md:w-72 md:flex overflow-hidden `}> <div className="p-4 overflow-y-auto h-full space-y-6"> {/* Grid Settings */} <div className="space-y-3"> <div className="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2"> <Settings size={12} /> Grid Setup </div> <div className="grid grid-cols-2 gap-3"> <div> <label className="text-[10px] text-slate-400 block mb-1">Rows</label> <input type="text" value={rowsInput} onChange={(e) => setRowsInput(e.target.value)} onBlur={() => { if(!rowsInput) setRowsInput("1"); }} className="w-full bg-slate-800 rounded px-2 py-1 text-sm border border-slate-700 focus:border-blue-500 outline-none" /> </div> <div> <label className="text-[10px] text-slate-400 block mb-1">Cols</label> <input type="text" value={colsInput} onChange={(e) => setColsInput(e.target.value)} onBlur={() => { if(!colsInput) setColsInput("1"); }} className="w-full bg-slate-800 rounded px-2 py-1 text-sm border border-slate-700 focus:border-blue-500 outline-none" /> </div> </div> </div> {/* Animations List */} <div className="space-y-3"> <div className="flex justify-between items-center"> <div className="text-xs font-bold text-slate-500 uppercase tracking-wider">Animations</div> <button onClick={() => { const newId = Math.max(0, ...animations.map(a => a.id)) + 1; setAnimations([...animations, { id: newId, name: `Anim ${newId}`, frames: '' }]); setSelectedAnimId(newId); }} className="p-1 bg-slate-800 hover:bg-slate-700 rounded text-slate-300"> <Plus size={14} /> </button> </div> <div className="space-y-2 max-h-[200px] overflow-y-auto"> {animations.map(anim => ( <div key={anim.id} onClick={() => setSelectedAnimId(anim.id)} className={`p-2 rounded text-sm cursor-pointer border ${selectedAnimId === anim.id ? 'bg-blue-900/30 border-blue-500/50 text-white' : 'bg-slate-800/30 border-transparent text-slate-400'}`} > <div className="flex justify-between"> <input value={anim.name} onChange={(e) => { const val = e.target.value; setAnimations(animations.map(a => a.id === anim.id ? {...a, name: val} : a)); }} onClick={(e) => e.stopPropagation()} className="bg-transparent outline-none w-32" /> {animations.length > 1 && ( <Trash2 size={14} className="text-slate-600 hover:text-red-400" onClick={(e) => { e.stopPropagation(); const rem = animations.filter(a => a.id !== anim.id); setAnimations(rem); if(selectedAnimId === anim.id) setSelectedAnimId(rem[0].id); }}/> )} </div> <div className="text-[10px] opacity-50 truncate mt-1">{anim.frames || "No frames"}</div> </div> ))} </div> </div> </div> </aside> )} {/* MAIN CONTENT */} <main className="flex-1 flex flex-col relative overflow-hidden bg-slate-950"> {/* Mobile Config Toggle */} {!isJitterMode && ( <div className="md:hidden bg-slate-900 border-b border-slate-800 p-2 flex justify-center"> <button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 text-xs text-slate-400"> {showConfig ? <ChevronUp size={14}/> : <ChevronDown size={14}/>} {showConfig ? "Hide Settings" : "Show Settings"} </button> </div> )} {/* CANVAS AREA (Expands in Jitter Mode) */} <div className={` relative flex flex-col items-center justify-center bg-[radial-gradient(#1e293b_1px,transparent_1px)] [background-size:20px_20px] ${isJitterMode ? 'flex-1 p-4' : 'shrink-0 border-b border-slate-800 py-6 px-4 min-h-[280px]'} `}> {!imageSrc ? ( <div className="text-center text-slate-500"> <ImageIcon size={32} className="mx-auto mb-2 opacity-50" /> <p className="text-sm">Upload a sprite sheet</p> </div> ) : ( <> {isJitterMode && ( <div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-3 bg-slate-900/90 backdrop-blur px-4 py-2 rounded-full border border-slate-700 shadow-xl z-20"> <div className="text-xs font-bold text-amber-500 flex items-center gap-2"> <Crosshair size={14} /> FIXING </div> <div className="w-px h-4 bg-slate-700"></div> <label className="flex items-center gap-1 text-[10px] text-slate-300 cursor-pointer select-none"> <input type="checkbox" checked={showGhostPrev} onChange={e => setShowGhostPrev(e.target.checked)} className="rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-0" /> Prev Ghost </label> <label className="flex items-center gap-1 text-[10px] text-slate-300 cursor-pointer select-none"> <input type="checkbox" checked={showGhostFirst} onChange={e => setShowGhostFirst(e.target.checked)} className="rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-0" /> First Ghost </label> </div> )} <canvas ref={canvasRef} width={imageDimensions.w / cols || 64} height={imageDimensions.h / rows || 64} className={` bg-transparent rounded shadow-2xl border border-slate-700 transition-all ${isJitterMode ? 'ring-2 ring-amber-500 shadow-[0_0_50px_rgba(0,0,0,0.5)]' : ''} `} style={{ // Logic: // Jitter Mode: Auto size (large as possible) // Preview Mode: // Base size is MIN(150px, ActualFrameSize) // Then multiply by user scale. // But we probably just want ActualFrameSize * Scale if possible, bounded by view? // Let's try: FrameSize * Scale width: isJitterMode ? 'auto' : (imageDimensions.w / cols) * previewScale, height: isJitterMode ? 'auto' : (imageDimensions.h / rows) * previewScale, maxHeight: isJitterMode ? '70vh' : '40vh', maxWidth: isJitterMode ? '90vw' : '90vw', imageRendering: 'pixelated' }} /> {/* Toolbar */} <div className={`${isJitterMode ? 'absolute bottom-8' : 'mt-8'} flex flex-wrap justify-center items-center gap-2 z-20 max-w-full`}> {isJitterMode ? ( // JITTER CONTROLS <div className="flex flex-col items-center gap-2 animate-in fade-in slide-in-from-bottom-4 duration-300"> <div className="flex items-center gap-2 bg-slate-900/90 p-1 rounded-lg border border-slate-700 shadow-xl backdrop-blur-md"> <button onClick={() => setJitterFrameStep(Math.max(0, jitterFrameStep - 1))} className="p-3 hover:bg-slate-800 rounded text-slate-400"><ChevronLeft size={20}/></button> <span className="text-sm font-mono w-16 text-center text-slate-300 font-bold">{jitterFrameStep + 1} / {frameList.length}</span> <button onClick={() => setJitterFrameStep(Math.min(frameList.length - 1, jitterFrameStep + 1))} className="p-3 hover:bg-slate-800 rounded text-slate-400"><ChevronRight size={20}/></button> <div className="w-px h-6 bg-slate-700 mx-1"></div> <button onClick={() => removeFrameAtIndex(jitterFrameStep)} className="p-3 hover:bg-red-900/50 text-slate-400 hover:text-red-500 rounded transition-colors" title="Delete current frame from sequence" > <Trash2 size={18} /> </button> </div> <div className="flex items-center gap-2"> {/* D-PAD */} <div className="grid grid-cols-3 gap-1 p-1 bg-slate-900/90 rounded-lg border border-slate-700 shadow-xl"> <div /> <button onClick={() => handleNudge(0, -1)} className="w-10 h-10 bg-slate-800 hover:bg-slate-700 rounded flex items-center justify-center active:scale-95"><ChevronUp size={16}/></button> <div /> <button onClick={() => handleNudge(-1, 0)} className="w-10 h-10 bg-slate-800 hover:bg-slate-700 rounded flex items-center justify-center active:scale-95"><ChevronLeft size={16}/></button> <div className="w-10 h-10 flex items-center justify-center text-[8px] text-slate-500 font-bold">MOVE</div> <button onClick={() => handleNudge(1, 0)} className="w-10 h-10 bg-slate-800 hover:bg-slate-700 rounded flex items-center justify-center active:scale-95"><ChevronRight size={16}/></button> <div /> <button onClick={() => handleNudge(0, 1)} className="w-10 h-10 bg-slate-800 hover:bg-slate-700 rounded flex items-center justify-center active:scale-95"><ChevronDown size={16}/></button> <div /> </div> <button onClick={() => setIsJitterMode(false)} className="h-full ml-2 px-6 bg-slate-700 hover:bg-emerald-600 text-white font-bold rounded-lg shadow-xl transition-colors flex flex-col items-center justify-center gap-1" > <span>DONE</span> </button> </div> </div> ) : ( // STANDARD CONTROLS <div className="flex flex-wrap items-center gap-2 bg-slate-900/80 p-2 rounded-lg border border-slate-700 backdrop-blur-sm"> <button onClick={() => setIsPlaying(!isPlaying)} className="w-8 h-8 flex items-center justify-center rounded-full bg-blue-600 text-white hover:scale-105 transition-transform"> {isPlaying ? <Pause size={14} /> : <Play size={14} />} </button> <div className="w-px h-6 bg-slate-700 mx-1"></div> <button onClick={() => { setIsJitterMode(true); setIsPlaying(false); setJitterFrameStep(0); }} className="flex items-center gap-2 px-3 py-1 rounded bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-500 transition-all border border-slate-700" title="Fix Jitter / Align Frames" > <Crosshair size={14} /> <span className="hidden sm:inline text-xs font-bold">Fix Jitter</span> </button> <div className="w-px h-6 bg-slate-700 mx-1"></div> {/* Speed Control */} <div className="flex flex-col w-16 sm:w-24"> <label className="text-[9px] text-slate-400 font-bold uppercase">Speed: {fps} FPS</label> <input type="range" min="1" max="30" value={fps} onChange={(e) => setFps(Number(e.target.value))} className="h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer" /> </div> <div className="w-px h-6 bg-slate-700 mx-1"></div> {/* Scale Control */} <div className="flex items-center gap-1"> <ZoomIn size={12} className="text-slate-500" /> <select value={previewScale} onChange={(e) => setPreviewScale(parseFloat(e.target.value))} className="bg-slate-800 text-xs text-slate-300 border border-slate-700 rounded px-1 py-0.5 outline-none hover:border-slate-500 focus:border-blue-500" > <option value={0.25}>0.25x</option> <option value={0.5}>0.5x</option> <option value={1}>1x</option> <option value={1.5}>1.5x</option> <option value={2}>2x</option> <option value={3}>3x</option> </select> </div> </div> )} </div> </> )} </div> {/* EDITOR / GRID AREA - Hidden in Jitter Mode */} {!isJitterMode && ( <div className="flex-1 flex flex-col bg-slate-950 overflow-hidden"> {/* Interactive Timeline */} <div className="p-2 bg-slate-900 border-b border-slate-800 flex gap-2 items-center overflow-x-auto min-h-[50px] scrollbar-thin scrollbar-thumb-slate-700"> <div className="flex items-center gap-1 px-2 shrink-0"> <span className="text-[10px] font-bold text-slate-500 uppercase">Timeline</span> </div> {frameList.length === 0 && <span className="text-xs text-slate-600 italic px-2">Tap grid images or add all...</span>} {frameList.map((frameNum, idx) => ( <div key={idx} onClick={() => removeFrameAtIndex(idx)} className="group relative flex flex-col items-center shrink-0 cursor-pointer" title="Click to remove this frame" > <div className="w-8 h-8 bg-slate-800 rounded border border-slate-700 flex items-center justify-center text-xs font-mono group-hover:border-red-500 group-hover:text-red-400 transition-colors"> {frameNum} </div> <div className="absolute -top-2 -right-1 hidden group-hover:flex w-3 h-3 bg-red-500 rounded-full items-center justify-center text-white"> <X size={8} /> </div> <div className="text-[8px] text-slate-600 mt-0.5">{idx + 1}</div> </div> ))} <div className="flex-1"></div> <div className="flex items-center gap-2 border-l border-slate-800 pl-2 shrink-0"> <button onClick={handleAddAllFrames} className="flex items-center gap-1 px-2 py-1 bg-slate-800 hover:bg-blue-900/50 text-slate-400 hover:text-blue-400 rounded text-xs border border-transparent hover:border-blue-500/30 transition-all" title="Add all frames in sequence" > <Grid size={12} /> <span className="font-medium">Add All</span> </button> <button onClick={handleClear} className="p-1.5 text-slate-400 hover:text-red-400 bg-slate-800 hover:bg-slate-700 rounded" title="Clear sequence"> <Trash2 size={14} /> </button> </div> </div> {/* Scrollable Grid */} <div className="flex-1 overflow-auto relative p-4 md:p-8 bg-slate-950"> {!imageSrc ? ( <div className="h-full flex flex-col items-center justify-center text-slate-600 text-sm pointer-events-none"> <MousePointer2 size={48} className="mb-4 opacity-20" /> <p>Drag & Drop Sprite Sheet Here</p> <p className="text-xs opacity-50 mt-2">or use Upload button</p> </div> ) : ( <div className="flex justify-center min-w-min"> <div className="relative shadow-2xl border border-slate-700 select-none" style={{ width: imageDimensions.w, height: imageDimensions.h, backgroundImage: `url(${imageSrc})`, backgroundSize: '100% 100%', transform: window.innerWidth < 768 ? 'scale(0.6)' : 'scale(1)', transformOrigin: 'top left' }} > <div className="absolute inset-0 grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }} > {Array.from({ length: rows * cols }).map((_, idx) => { const orderIndices = frameList.reduce((acc, frameId, pos) => { if (frameId === idx) acc.push(pos + 1); return acc; }, []); const isSelected = orderIndices.length > 0; const hasOffset = offsets[idx] && (offsets[idx].x !== 0 || offsets[idx].y !== 0); return ( <div key={idx} onClick={() => handleGridClick(idx)} className={` border border-white/10 relative cursor-pointer transition-colors active:bg-blue-500/50 ${isSelected ? 'bg-green-500/10' : 'hover:bg-white/5'} `} > <span className="absolute top-0 left-0 text-[8px] md:text-[10px] text-white/50 p-0.5 font-mono bg-black/40"> {idx} </span> {hasOffset && ( <div className="absolute top-0 right-0 text-[6px] bg-amber-500 text-black px-1 font-bold"> MOD </div> )} {isSelected && ( <div className="absolute inset-0 flex items-center justify-center flex-wrap gap-0.5 content-center p-1"> {orderIndices.slice(0, 3).map(order => ( <span key={order} className="bg-green-500 text-slate-900 text-[8px] font-bold px-1 rounded-sm shadow-sm"> {order} </span> ))} {orderIndices.length > 3 && <span className="text-[8px] text-green-400">...</span>} </div> )} </div> ); })} </div> </div> </div> )} </div> </div> )} </main> </div> </div> ); }; export default App;
No revisions found. Save the file to create a backup.
Delete
Update App