tiny-sprite/src/dev/app.tsx
Corey Johnson 311b91d0c5 refactor: pure CSS sprite animation with CSS variables
- Remove runtime JavaScript from Sprite component entirely
- Use CSS custom properties (--x, --y, --ex) with shared @keyframes
- Add SpriteStyles component for global keyframe (include once in <head>)
- Require sheetWidth/sheetHeight props (calculated by dev tool)
- Remove grid/columns support (horizontal strips only)
- Dev tool now auto-detects frames and calculates crop bounds
- Dev server is now CLI-based with required assets directory arg
- Add image picker with filter/search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 16:07:35 -08:00

416 lines
12 KiB
TypeScript

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
$<HTMLInputElement>("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($<HTMLInputElement>("filter").value.toLowerCase())
}
img.src = url
}
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T
const updateCropInputs = () => {
$<HTMLInputElement>("crop-x").value = String(state.crop?.x ?? 0)
$<HTMLInputElement>("crop-y").value = String(state.crop?.y ?? 0)
$<HTMLInputElement>("crop-width").value = String(state.crop?.width ?? 0)
$<HTMLInputElement>("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"
$<HTMLTextAreaElement>("code").value = `<Sprite
src="${srcName}"
frames={${frames}}
frameDuration={${frameDuration}}
sheetWidth={${imageWidth}}
sheetHeight={${imageHeight}}${cropAttr}${scaleAttr}
/>`
}
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 = () => (
<div class="grid">
<div>
<label>
Filter
<input
type="text"
id="filter"
placeholder="Search images..."
onInput={(e) => {
const filter = (e.target as HTMLInputElement).value.toLowerCase()
renderImagePicker(filter)
}}
/>
</label>
<div id="image-picker" style={{ marginBottom: "1rem", display: "none" }}></div>
<div
id="error-message"
style={{
display: "none",
padding: "0.5rem",
marginBottom: "1rem",
background: "var(--pico-del-color)",
borderRadius: "var(--pico-border-radius)",
color: "white",
}}
></div>
<label>
Frame Count
<input
type="number"
id="frames"
value={state.frames}
min={1}
onInput={(e) => {
state.frames = +(e.target as HTMLInputElement).value || 1
// Recalculate crop when frames change
if (state.spriteUrl) {
const img = new Image()
img.onload = () => {
state.crop = calculateCrop(img, state.frames)
updateCropInputs()
saveToStorage()
updatePreview()
}
img.src = state.spriteUrl
} else {
updatePreview()
}
}}
/>
</label>
<label>
Frame Duration (ms)
<input
type="number"
id="frameDuration"
value={state.frameDuration}
min={1}
onInput={(e) => {
state.frameDuration = +(e.target as HTMLInputElement).value || 100
updatePreview()
}}
/>
</label>
<label>
<span id="scale-label">Scale (1x)</span>
<input
type="range"
id="scale"
value={state.scale}
min={1}
max={8}
step={1}
onInput={(e) => {
state.scale = +(e.target as HTMLInputElement).value || 1
updatePreview()
}}
/>
</label>
<small id="frame-info"></small>
<fieldset>
<legend>Crop</legend>
<div class="grid">
<label>
X
<input
type="number"
id="crop-x"
value={state.crop?.x ?? 0}
min={0}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 0
state.crop = { ...state.crop!, x: val }
saveToStorage()
updatePreview()
}}
/>
</label>
<label>
Y
<input
type="number"
id="crop-y"
value={state.crop?.y ?? 0}
min={0}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 0
state.crop = { ...state.crop!, y: val }
saveToStorage()
updatePreview()
}}
/>
</label>
</div>
<div class="grid">
<label>
Width
<input
type="number"
id="crop-width"
value={state.crop?.width ?? 0}
min={1}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 1
state.crop = { ...state.crop!, width: val }
saveToStorage()
updatePreview()
}}
/>
</label>
<label>
Height
<input
type="number"
id="crop-height"
value={state.crop?.height ?? 0}
min={1}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 1
state.crop = { ...state.crop!, height: val }
saveToStorage()
updatePreview()
}}
/>
</label>
</div>
</fieldset>
</div>
<div>
<div class="preview-area" style={{ position: "relative", overflow: "hidden" }}>
<style id="preview-style"></style>
<div id="preview-container" style={{ position: "relative" }}>
<div id="preview" style={{ display: "none" }}></div>
<div id="crop-overlay" style={{ display: "none", pointerEvents: "none" }}></div>
</div>
<span id="preview-placeholder">Upload a spritesheet to preview</span>
</div>
<label>
Generated Code
<textarea id="code" readonly rows={10}></textarea>
</label>
<button onClick={() => navigator.clipboard.writeText($<HTMLTextAreaElement>("code").value)}>
Copy Code
</button>
</div>
</div>
)
render(<App />, document.getElementById("app")!)
// Initial update after render
setTimeout(() => {
$<HTMLInputElement>("frames").value = String(state.frames)
updateCropInputs()
updatePreview()
fetchImages()
}, 0)