import { render } from "hono/jsx/dom" import { analyzeSprite, calculateCrop, type Crop } from "./analyze" const STORAGE_KEY = "tiny-sprites-last-file" const state = { spriteUrl: "", fileName: "", relativePath: "", // Path relative to assets dir for generated code imageWidth: 0, imageHeight: 0, frames: 4, frameDuration: 100, scale: 1, crop: undefined as Crop | undefined, availableImages: [] as string[], } // Load from localStorage try { const stored = localStorage.getItem(STORAGE_KEY) if (stored) { const data = JSON.parse(stored) state.spriteUrl = data.spriteUrl || "" state.fileName = data.fileName || "" state.relativePath = data.relativePath || "" state.imageWidth = data.imageWidth || 0 state.imageHeight = data.imageHeight || 0 state.frames = data.frames || 4 state.crop = data.crop } } catch {} const saveToStorage = () => { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ spriteUrl: state.spriteUrl, fileName: state.fileName, relativePath: state.relativePath, imageWidth: state.imageWidth, imageHeight: state.imageHeight, frames: state.frames, crop: state.crop, }) ) } catch {} } const loadImageFromPath = (path: string) => { const url = `/assets/${encodeURIComponent(path).replace(/%2F/g, "/")}` const img = new Image() img.onload = () => { state.spriteUrl = url state.fileName = path.split("/").pop() || path state.relativePath = path state.imageWidth = img.width state.imageHeight = img.height try { const result = analyzeSprite(img) state.frames = result.frames state.crop = result.crop $("frames").value = String(result.frames) updateCropInputs() $("error-message").style.display = "none" } catch (e) { state.crop = undefined const msg = e instanceof Error ? e.message : "Unknown error" $("error-message").textContent = `Auto-detect failed: ${msg}. Set frame count manually.` $("error-message").style.display = "block" } saveToStorage() updatePreview() renderImagePicker($("filter").value.toLowerCase()) } img.src = url } const $ = (id: string) => document.getElementById(id) as T const updateCropInputs = () => { $("crop-x").value = String(state.crop?.x ?? 0) $("crop-y").value = String(state.crop?.y ?? 0) $("crop-width").value = String(state.crop?.width ?? 0) $("crop-height").value = String(state.crop?.height ?? 0) } const updatePreview = () => { const { spriteUrl, imageWidth, imageHeight, frames, frameDuration, scale, crop } = state const frameWidth = imageWidth > 0 ? Math.floor(imageWidth / frames) : 32 const frameHeight = imageHeight > 0 ? imageHeight : 32 // Use crop dimensions if available, otherwise full frame const displayWidth = crop ? crop.width : frameWidth const displayHeight = crop ? crop.height : frameHeight const offsetX = crop ? crop.x : 0 const offsetY = crop ? crop.y : 0 const totalDuration = frames * frameDuration // Update info displays $("frame-info").textContent = spriteUrl ? `Frame: ${frameWidth} x ${frameHeight} px` : "" $("scale-label").textContent = `Scale (${scale}x)` // Update preview - show full frame with crop overlay const preview = $("preview") const previewStyle = $("preview-style") const cropOverlay = $("crop-overlay") if (spriteUrl) { const keyframeId = "sprite-preview" const fullKeyframes = `@keyframes ${keyframeId}{from{background-position:0 0}to{background-position:-${imageWidth * scale}px 0}}` previewStyle.textContent = fullKeyframes preview.style.width = `${frameWidth * scale}px` preview.style.height = `${frameHeight * scale}px` preview.style.backgroundImage = `url('${spriteUrl}')` preview.style.backgroundSize = `${imageWidth * scale}px ${imageHeight * scale}px` preview.style.animation = `${keyframeId} ${totalDuration}ms steps(${frames}) infinite` preview.style.display = "block" preview.style.position = "relative" // Position crop overlay to show what's kept vs cropped if (crop) { cropOverlay.style.position = "absolute" cropOverlay.style.top = `${offsetY * scale}px` cropOverlay.style.left = `${offsetX * scale}px` cropOverlay.style.width = `${displayWidth * scale}px` cropOverlay.style.height = `${displayHeight * scale}px` cropOverlay.style.boxShadow = `0 0 0 9999px rgba(0, 0, 0, 0.6)` cropOverlay.style.display = "block" } else { cropOverlay.style.display = "none" } $("preview-placeholder").style.display = "none" } else { preview.style.display = "none" cropOverlay.style.display = "none" $("preview-placeholder").style.display = "block" } // Update generated code const cropAttr = crop ? `\n crop={{ x: ${crop.x}, y: ${crop.y}, width: ${crop.width}, height: ${crop.height} }}` : "" const scaleAttr = scale !== 1 ? `\n scale={${scale}}` : "" const srcName = state.relativePath || state.fileName || "YOUR_SPRITE_URL" $("code").value = `` } const renderImagePicker = (filter = "") => { const container = $("image-picker") if (state.availableImages.length === 0) { container.style.display = "none" return } const filtered = filter ? state.availableImages.filter((p) => p.toLowerCase().includes(filter)) : state.availableImages container.style.display = "block" container.innerHTML = "" const list = document.createElement("div") list.style.maxHeight = "300px" list.style.overflowY = "auto" list.style.background = "var(--pico-card-background-color)" list.style.borderRadius = "var(--pico-border-radius)" for (const path of filtered) { const item = document.createElement("div") item.style.cursor = "pointer" item.style.display = "flex" item.style.flexDirection = "column" item.style.alignItems = "center" item.style.gap = "4px" item.style.padding = "12px" item.style.borderBottom = "1px solid var(--pico-muted-border-color)" if (state.relativePath === path) { item.style.background = "var(--pico-primary-background)" } item.onmouseenter = () => { if (state.relativePath !== path) { item.style.background = "var(--pico-secondary-background)" } } item.onmouseleave = () => { if (state.relativePath !== path) { item.style.background = "" } } const img = document.createElement("img") img.src = `/assets/${encodeURIComponent(path).replace(/%2F/g, "/")}` img.style.maxWidth = "100%" img.style.height = "64px" img.style.objectFit = "contain" img.style.imageRendering = "pixelated" const label = document.createElement("div") label.style.fontSize = "11px" label.style.wordBreak = "break-all" label.style.textAlign = "center" label.textContent = path item.appendChild(img) item.appendChild(label) item.onclick = () => loadImageFromPath(path) list.appendChild(item) } container.appendChild(list) } const fetchImages = async () => { try { const res = await fetch("/api/images") const data = await res.json() state.availableImages = data.images || [] renderImagePicker() } catch { state.availableImages = [] } } const App = () => (
Crop
Upload a spritesheet to preview
) render(, document.getElementById("app")!) // Initial update after render setTimeout(() => { $("frames").value = String(state.frames) updateCropInputs() updatePreview() fetchImages() }, 0)