- 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>
416 lines
12 KiB
TypeScript
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)
|