diff --git a/CLAUDE.md b/CLAUDE.md index bec1f8b..5806c6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,11 @@ Access at http://localhost:{web_port} (configured in config.json) - **admin/** - Admin panel (login + tabbed settings UI) - Theme management: rename display names, upload per-direction background PNGs, create new themes - Display names stored in `config.json` `theme_names` map (folder name -> display name) + - **pixel-editor.js** - In-app Canvas2D pixel editor (Sprites tab "EDIT" button) + - Edits sprites at native 500x333; tools: pencil, eraser, color picker, fill bucket, pan + - Zoom/pan, undo/redo (snapshot-per-stroke), grid overlay, retro palette + native color input + - Exports a 500x333 PNG via `srcCanvas.toBlob` and POSTs to the existing `/api/admin/sprites/{type}` upload endpoint (no new server route) + - Keyboard: B/E/I/F tools, Space=pan, +/-/0 zoom, Ctrl+Z/Y undo/redo, G grid, Esc close - **setup/** - First-run setup wizard (multi-step configuration) diff --git a/admin/admin.css b/admin/admin.css index 44d6cd7..cc1dc4f 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -592,6 +592,244 @@ textarea { font-size: 11px; } +/* =================================================================== + Pixel Editor + =================================================================== */ +.pe-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +body.pe-open { overflow: hidden; } + +.pe-modal { + background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); + border: 3px solid #fcd444; + border-radius: 12px; + width: 100%; + max-width: 1100px; + height: 100%; + max-height: 760px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6); + overflow: hidden; +} + +.pe-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.3); + border-bottom: 2px solid #5c94fc; + flex-wrap: wrap; + gap: 8px; +} + +.pe-header .retro-title { + font-size: 13px; + margin: 0; +} + +.pe-header-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.pe-body { + flex: 1; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr; + min-height: 0; +} + +/* Toolbar (left column) */ +.pe-toolbar { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 10px; + background: rgba(0, 0, 0, 0.25); + border-right: 2px solid #5c94fc; + overflow-y: auto; +} + +.pe-tool-group { + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(92, 148, 252, 0.25); +} + +.pe-tool-group:last-child { border-bottom: none; padding-bottom: 0; } + +.pe-tool, .pe-icon-btn { + width: 44px; + height: 44px; + font-size: 20px; + line-height: 1; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(92, 148, 252, 0.15); + color: #fcfcfc; + cursor: pointer; + transition: all 0.12s; + display: flex; + align-items: center; + justify-content: center; +} + +.pe-tool:hover, .pe-icon-btn:hover:not(:disabled) { + background: rgba(92, 148, 252, 0.4); +} + +.pe-tool.active { + background: rgba(252, 212, 68, 0.3); + border-color: #fcd444; + color: #fcd444; +} + +.pe-icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.pe-tool-label { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + text-align: center; +} + +.pe-check { + display: flex; + align-items: center; + gap: 6px; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + cursor: pointer; +} + +.pe-check input { width: 16px; height: 16px; accent-color: #fcd444; cursor: pointer; } + +.pe-brush-group { align-items: center; } +.pe-brush-group input[type="range"] { width: 44px; accent-color: #fcd444; } + +/* Canvas area (center column) */ +.pe-canvas-wrap { + position: relative; + min-width: 0; + background: #14202b; + overflow: hidden; +} + +#pe-view { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +/* Sidebar (right column) */ +.pe-sidebar { + width: 150px; + padding: 14px 12px; + background: rgba(0, 0, 0, 0.25); + border-left: 2px solid #5c94fc; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; +} + +.pe-color-current { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.pe-swatch-large { + width: 100%; + height: 48px; + border: 2px solid #fcfcfc; + border-radius: 6px; + background: #5c94fc; +} + +#pe-color-input { + width: 100%; + height: 32px; + padding: 2px; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.pe-palette { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; +} + +.pe-swatch { + aspect-ratio: 1; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 3px; + cursor: pointer; + padding: 0; + transition: transform 0.1s; +} + +.pe-swatch:hover { + transform: scale(1.12); + border-color: #fcd444; +} + +.pe-readout { + margin-top: auto; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + line-height: 2.2; +} + +.pe-readout span { color: #54fc54; } + +/* Sprite card EDIT button uses primary accent */ +.sprite-edit-btn { border-color: #fcd444; color: #fcd444; } +.sprite-edit-btn:hover { background: rgba(252, 212, 68, 0.3); } + +@media (max-width: 768px) { + .pe-modal { max-height: none; border-radius: 0; } + .pe-body { grid-template-columns: auto 1fr; } + .pe-sidebar { + grid-column: 1 / -1; + grid-row: 2; + width: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-left: none; + border-top: 2px solid #5c94fc; + } + .pe-body { grid-template-rows: 1fr auto; } + .pe-palette { flex: 1; } + .pe-readout { margin-top: 0; } +} + /* Mobile */ @media (max-width: 768px) { .admin-header { diff --git a/admin/admin.html b/admin/admin.html index aeb4d84..77d3909 100644 --- a/admin/admin.html +++ b/admin/admin.html @@ -265,6 +265,7 @@ + diff --git a/admin/admin.js b/admin/admin.js index 46b640d..7341ff0 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -522,6 +522,7 @@
${sprite.categories || ''}
${imgHtml}
+
`; + const editBtn = card.querySelector('.sprite-edit-btn'); + editBtn.addEventListener('click', () => { + if (window.PixelEditor) { + window.PixelEditor.open(sprite.type, { onSave: loadSprites }); + } else { + toast('Pixel editor not loaded', true); + } + }); + const fileInput = card.querySelector('.sprite-upload-input'); fileInput.addEventListener('change', async (e) => { const file = e.target.files[0]; diff --git a/admin/pixel-editor.js b/admin/pixel-editor.js new file mode 100644 index 0000000..8d257d1 --- /dev/null +++ b/admin/pixel-editor.js @@ -0,0 +1,719 @@ +// ADS-Bit In-App Pixel Editor +// A dependency-free Canvas2D sprite editor for the admin Sprites tab. +// Edits sprites at their native 500x333 resolution and exports a PNG that +// is POSTed to the existing /api/admin/sprites/{type} endpoint. +// +// Public API: +// PixelEditor.open(spriteType, { existingUrl, onSave }) +// +// Tools: pencil, eraser, color picker, fill bucket, pan. +// Features: zoom/pan, undo/redo, grid overlay, load existing, export PNG. +(function () { + 'use strict'; + + // ----- Sprite dimensions (fixed by the sprite spec) ----- + const SPRITE_W = 500; + const SPRITE_H = 333; + + // ----- Tunables ----- + const MIN_ZOOM = 0.5; + const MAX_ZOOM = 40; + const HISTORY_LIMIT = 40; // max undo snapshots (~666 KB each) + const GRID_MIN_ZOOM = 6; // only show pixel grid when zoomed in enough + + // ----- Retro palette (SNES-style white/blue/red leaning) ----- + const PALETTE = [ + '#000000', '#1a1a1a', '#3c3c3c', '#5c5c5c', '#9c9c9c', '#fcfcfc', + '#5c94fc', '#3c5cac', '#1a2c6c', '#b4d4ec', '#54b4fc', '#0c84fc', + '#fc5454', '#ac2c2c', '#fc9c9c', '#fcd444', '#fc9838', '#a85400', + '#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc', + ]; + + // ----- Module state ----- + let root = null; // overlay element + let els = {}; // cached DOM references + + let srcCanvas = null; // 500x333 source-of-truth canvas + let srcCtx = null; + let viewCanvas = null; // on-screen scaled render target + let viewCtx = null; + + let tool = 'pencil'; + let brushSize = 1; + let color = '#5c94fc'; + let zoom = 1; + let panX = 0, panY = 0; + let showGrid = true; + + let spriteType = null; + let saveCallback = null; + + // Pointer interaction state + let drawing = false; // a draw stroke is in progress + let panning = false; // a pan drag is in progress + let spaceDown = false; // hold space to temporarily pan + let lastDraw = null; // last source pixel during a stroke {x,y} + let panStart = null; // {x,y,panX,panY} at pan start + + // Undo/redo + let history = []; + let historyIndex = -1; + + // =================================================================== + // DOM construction + // =================================================================== + function build() { + if (root) return; + + root = document.createElement('div'); + root.id = 'pe-overlay'; + root.className = 'pe-overlay hidden'; + root.innerHTML = ` +
+
+

PIXEL EDITOR

+
+ + + + +
+
+
+
+
+ + + + + +
+
+ + +
+
+ + + +
+
+ +
+
+ SIZE + + 1 +
+
+
+ +
+
+
+
+ +
+
+
+
POS --, --
+
ZOOM 100%
+
SIZE 500×333
+
+
+
+
+ `; + document.body.appendChild(root); + + // Cache references + els.title = root.querySelector('#pe-title'); + els.wrap = root.querySelector('#pe-canvas-wrap'); + els.palette = root.querySelector('#pe-palette'); + els.currentSwatch = root.querySelector('#pe-current-swatch'); + els.colorInput = root.querySelector('#pe-color-input'); + els.pos = root.querySelector('#pe-pos'); + els.zoomVal = root.querySelector('#pe-zoom-val'); + els.brush = root.querySelector('#pe-brush'); + els.brushVal = root.querySelector('#pe-brush-val'); + els.grid = root.querySelector('#pe-grid'); + els.undo = root.querySelector('#pe-undo'); + els.redo = root.querySelector('#pe-redo'); + + viewCanvas = root.querySelector('#pe-view'); + viewCtx = viewCanvas.getContext('2d'); + + // Source-of-truth canvas (offscreen) + srcCanvas = document.createElement('canvas'); + srcCanvas.width = SPRITE_W; + srcCanvas.height = SPRITE_H; + srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); + + buildPalette(); + wireEvents(); + } + + function buildPalette() { + els.palette.innerHTML = ''; + PALETTE.forEach(c => { + const sw = document.createElement('button'); + sw.className = 'pe-swatch'; + sw.style.background = c; + sw.title = c; + sw.addEventListener('click', () => setColor(c)); + els.palette.appendChild(sw); + }); + } + + // =================================================================== + // Event wiring + // =================================================================== + function wireEvents() { + // Tool buttons + root.querySelectorAll('.pe-tool').forEach(btn => { + btn.addEventListener('click', () => setTool(btn.dataset.tool)); + }); + + // Header actions + root.querySelector('#pe-close').addEventListener('click', () => { + if (confirmDiscard()) close(); + }); + root.querySelector('#pe-save').addEventListener('click', save); + root.querySelector('#pe-load').addEventListener('click', () => loadExisting(true)); + root.querySelector('#pe-clear').addEventListener('click', clearCanvas); + + // Zoom controls + root.querySelector('#pe-zoom-in').addEventListener('click', () => zoomAt(1.25, centerPoint())); + root.querySelector('#pe-zoom-out').addEventListener('click', () => zoomAt(0.8, centerPoint())); + root.querySelector('#pe-zoom-fit').addEventListener('click', fitToScreen); + + // Undo/redo + els.undo.addEventListener('click', undo); + els.redo.addEventListener('click', redo); + + // Grid + brush + els.grid.addEventListener('change', () => { showGrid = els.grid.checked; render(); }); + els.brush.addEventListener('input', () => { + brushSize = parseInt(els.brush.value, 10); + els.brushVal.textContent = brushSize; + }); + + // Color input + els.colorInput.addEventListener('input', () => setColor(els.colorInput.value)); + + // Canvas pointer events + viewCanvas.addEventListener('pointerdown', onPointerDown); + viewCanvas.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + viewCanvas.addEventListener('pointerleave', () => { els.pos.textContent = '--, --'; }); + viewCanvas.addEventListener('wheel', onWheel, { passive: false }); + viewCanvas.addEventListener('contextmenu', e => e.preventDefault()); + + // Keyboard shortcuts (only active while editor is open) + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + + // Keep the view sized to its container + window.addEventListener('resize', () => { if (isOpen()) { resizeView(); render(); } }); + } + + // =================================================================== + // Public open/close + // =================================================================== + function open(type, opts) { + opts = opts || {}; + build(); + spriteType = type; + saveCallback = opts.onSave || null; + els.title.textContent = 'PIXEL EDITOR — ' + type; + + // Reset state + zoom = 1; panX = 0; panY = 0; + history = []; historyIndex = -1; + setTool('pencil'); + srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); + + root.classList.remove('hidden'); + document.body.classList.add('pe-open'); + + // Load existing sprite (if any), then size + draw. + loadExisting(false, () => { + resizeView(); + fitToScreen(); + pushHistory(); // baseline snapshot + updateHistoryButtons(); + }); + } + + function isOpen() { return root && !root.classList.contains('hidden'); } + + function close() { + root.classList.add('hidden'); + document.body.classList.remove('pe-open'); + spriteType = null; + saveCallback = null; + } + + function confirmDiscard() { + // Only warn if there is edit history beyond the baseline. + if (historyIndex > 0) { + return window.confirm('Discard unsaved changes and close the editor?'); + } + return true; + } + + // =================================================================== + // Loading / clearing + // =================================================================== + function loadExisting(recordHistory, done) { + const url = `/images/${spriteType}.png?v=${Date.now()}`; + const img = new Image(); + img.onload = () => { + srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); + // Draw centered/stretched into the 500x333 buffer. + srcCtx.imageSmoothingEnabled = false; + srcCtx.drawImage(img, 0, 0, SPRITE_W, SPRITE_H); + if (recordHistory) { pushHistory(); toast('Loaded current sprite'); } + render(); + if (done) done(); + }; + img.onerror = () => { + // No existing sprite — start from a blank transparent canvas. + if (recordHistory) { pushHistory(); toast('No existing sprite — blank canvas', true); } + render(); + if (done) done(); + }; + img.src = url; + } + + function clearCanvas() { + srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); + pushHistory(); + render(); + } + + // =================================================================== + // Tool / color selection + // =================================================================== + function setTool(t) { + tool = t; + root.querySelectorAll('.pe-tool').forEach(b => { + b.classList.toggle('active', b.dataset.tool === t); + }); + updateCursor(); + } + + function setColor(c) { + color = c; + els.currentSwatch.style.background = c; + if (els.colorInput.value.toLowerCase() !== c.toLowerCase()) { + els.colorInput.value = c; + } + } + + function updateCursor() { + let cur = 'crosshair'; + if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab'; + else if (tool === 'picker') cur = 'cell'; + viewCanvas.style.cursor = cur; + } + + // =================================================================== + // Coordinate mapping + // =================================================================== + function eventToSource(e) { + const rect = viewCanvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + return { + x: Math.floor((cx - panX) / zoom), + y: Math.floor((cy - panY) / zoom), + cx, cy, + }; + } + + function centerPoint() { + return { cx: viewCanvas.width / 2, cy: viewCanvas.height / 2 }; + } + + // =================================================================== + // Pointer handlers + // =================================================================== + function onPointerDown(e) { + e.preventDefault(); + viewCanvas.setPointerCapture(e.pointerId); + const p = eventToSource(e); + + // Pan: pan tool, space held, or middle mouse. + if (tool === 'pan' || spaceDown || e.button === 1) { + panning = true; + panStart = { cx: e.clientX, cy: e.clientY, panX, panY }; + updateCursor(); + return; + } + + if (tool === 'picker') { + pickColor(p.x, p.y); + return; + } + + if (tool === 'fill') { + floodFill(p.x, p.y); + pushHistory(); + render(); + return; + } + + // Pencil / eraser stroke begins. + drawing = true; + lastDraw = null; + applyBrush(p.x, p.y); + lastDraw = { x: p.x, y: p.y }; + render(); + } + + function onPointerMove(e) { + const p = eventToSource(e); + if (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H) { + els.pos.textContent = `${p.x}, ${p.y}`; + } else { + els.pos.textContent = '--, --'; + } + + if (panning && panStart) { + panX = panStart.panX + (e.clientX - panStart.cx); + panY = panStart.panY + (e.clientY - panStart.cy); + render(); + return; + } + + if (drawing && (tool === 'pencil' || tool === 'eraser')) { + // Interpolate from the last point so fast strokes have no gaps. + if (lastDraw) lineApply(lastDraw.x, lastDraw.y, p.x, p.y); + else applyBrush(p.x, p.y); + lastDraw = { x: p.x, y: p.y }; + render(); + } + } + + function onPointerUp() { + if (drawing) { + drawing = false; + lastDraw = null; + pushHistory(); // one undo step per completed stroke + } + if (panning) { + panning = false; + panStart = null; + updateCursor(); + } + } + + function onWheel(e) { + e.preventDefault(); + const rect = viewCanvas.getBoundingClientRect(); + const focus = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; + zoomAt(e.deltaY < 0 ? 1.15 : 0.87, focus); + } + + // =================================================================== + // Drawing primitives (operate on the 500x333 source canvas) + // =================================================================== + function applyBrush(x, y) { + const half = Math.floor(brushSize / 2); + const x0 = x - half, y0 = y - half; + if (tool === 'eraser') { + srcCtx.clearRect(x0, y0, brushSize, brushSize); + } else { + srcCtx.fillStyle = color; + srcCtx.fillRect(x0, y0, brushSize, brushSize); + } + } + + // Bresenham line between two source pixels, stamping the brush along it. + function lineApply(x0, y0, x1, y1) { + const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + applyBrush(x0, y0); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + } + + function pickColor(x, y) { + if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return; + const d = srcCtx.getImageData(x, y, 1, 1).data; + if (d[3] === 0) { toast('Picked transparent pixel', true); return; } + setColor(rgbToHex(d[0], d[1], d[2])); + setTool('pencil'); + } + + // 4-neighbour flood fill with exact-color matching. + function floodFill(sx, sy) { + if (sx < 0 || sx >= SPRITE_W || sy < 0 || sy >= SPRITE_H) return; + const img = srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H); + const data = img.data; + const idx = (sy * SPRITE_W + sx) * 4; + const target = [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]]; + const fill = hexToRgb(color); + const fillA = 255; + + // No-op if the target already equals the fill color. + if (target[0] === fill.r && target[1] === fill.g && + target[2] === fill.b && target[3] === fillA) return; + + const stack = [sy * SPRITE_W + sx]; + while (stack.length) { + const pos = stack.pop(); + const o = pos * 4; + if (data[o] !== target[0] || data[o + 1] !== target[1] || + data[o + 2] !== target[2] || data[o + 3] !== target[3]) continue; + data[o] = fill.r; data[o + 1] = fill.g; data[o + 2] = fill.b; data[o + 3] = fillA; + const px = pos % SPRITE_W, py = (pos - px) / SPRITE_W; + if (px > 0) stack.push(pos - 1); + if (px < SPRITE_W - 1) stack.push(pos + 1); + if (py > 0) stack.push(pos - SPRITE_W); + if (py < SPRITE_H - 1) stack.push(pos + SPRITE_W); + } + srcCtx.putImageData(img, 0, 0); + } + + // =================================================================== + // Zoom / pan + // =================================================================== + function zoomAt(factor, focus) { + const newZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM); + if (newZoom === zoom) return; + // Keep the source pixel under the focus point stationary. + const sxBefore = (focus.cx - panX) / zoom; + const syBefore = (focus.cy - panY) / zoom; + zoom = newZoom; + panX = focus.cx - sxBefore * zoom; + panY = focus.cy - syBefore * zoom; + render(); + } + + function fitToScreen() { + const w = viewCanvas.width, h = viewCanvas.height; + const margin = 24; + zoom = clamp(Math.min((w - margin) / SPRITE_W, (h - margin) / SPRITE_H), MIN_ZOOM, MAX_ZOOM); + panX = (w - SPRITE_W * zoom) / 2; + panY = (h - SPRITE_H * zoom) / 2; + render(); + } + + function resizeView() { + const rect = els.wrap.getBoundingClientRect(); + viewCanvas.width = Math.max(100, Math.floor(rect.width)); + viewCanvas.height = Math.max(100, Math.floor(rect.height)); + } + + // =================================================================== + // Rendering + // =================================================================== + function render() { + const w = viewCanvas.width, h = viewCanvas.height; + viewCtx.clearRect(0, 0, w, h); + + const dw = SPRITE_W * zoom, dh = SPRITE_H * zoom; + + // Transparency checkerboard behind the artboard. + drawCheckerboard(panX, panY, dw, dh); + + // The sprite itself, nearest-neighbour scaled. + viewCtx.imageSmoothingEnabled = false; + viewCtx.drawImage(srcCanvas, panX, panY, dw, dh); + + // Artboard border. + viewCtx.strokeStyle = '#5c94fc'; + viewCtx.lineWidth = 1; + viewCtx.strokeRect(panX + 0.5, panY + 0.5, dw, dh); + + if (showGrid && zoom >= GRID_MIN_ZOOM) drawGrid(dw, dh); + + els.zoomVal.textContent = Math.round(zoom * 100) + '%'; + } + + function drawCheckerboard(ox, oy, dw, dh) { + const tile = 8; + // Clip to artboard so the checker stays inside the sprite bounds. + viewCtx.save(); + viewCtx.beginPath(); + viewCtx.rect(ox, oy, dw, dh); + viewCtx.clip(); + const startX = Math.floor(ox / tile) * tile; + const startY = Math.floor(oy / tile) * tile; + for (let y = startY; y < oy + dh; y += tile) { + for (let x = startX; x < ox + dw; x += tile) { + const odd = ((x / tile) + (y / tile)) % 2 !== 0; + viewCtx.fillStyle = odd ? '#2c2c2c' : '#3c3c3c'; + viewCtx.fillRect(x, y, tile, tile); + } + } + viewCtx.restore(); + } + + function drawGrid(dw, dh) { + viewCtx.strokeStyle = 'rgba(255,255,255,0.18)'; + viewCtx.lineWidth = 1; + viewCtx.beginPath(); + for (let x = 0; x <= SPRITE_W; x++) { + const px = Math.round(panX + x * zoom) + 0.5; + viewCtx.moveTo(px, panY); + viewCtx.lineTo(px, panY + dh); + } + for (let y = 0; y <= SPRITE_H; y++) { + const py = Math.round(panY + y * zoom) + 0.5; + viewCtx.moveTo(panX, py); + viewCtx.lineTo(panX + dw, py); + } + viewCtx.stroke(); + } + + // =================================================================== + // Undo / redo + // =================================================================== + function pushHistory() { + // Drop any redo tail. + if (historyIndex < history.length - 1) { + history = history.slice(0, historyIndex + 1); + } + history.push(srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)); + // Cap memory use. + if (history.length > HISTORY_LIMIT) history.shift(); + historyIndex = history.length - 1; + updateHistoryButtons(); + } + + function undo() { + if (historyIndex <= 0) return; + historyIndex--; + srcCtx.putImageData(history[historyIndex], 0, 0); + render(); + updateHistoryButtons(); + } + + function redo() { + if (historyIndex >= history.length - 1) return; + historyIndex++; + srcCtx.putImageData(history[historyIndex], 0, 0); + render(); + updateHistoryButtons(); + } + + function updateHistoryButtons() { + els.undo.disabled = historyIndex <= 0; + els.redo.disabled = historyIndex >= history.length - 1; + } + + // =================================================================== + // Save / export + // =================================================================== + function save() { + if (!spriteType) return; + const saveBtn = root.querySelector('#pe-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'SAVING…'; + srcCanvas.toBlob(async (blob) => { + if (!blob) { + toast('Export failed', true); + saveBtn.disabled = false; saveBtn.textContent = 'SAVE'; + return; + } + const fd = new FormData(); + fd.append('file', blob, `${spriteType}.png`); + try { + const res = await fetch(`/api/admin/sprites/${spriteType}`, { + method: 'POST', body: fd, + }); + if (res.ok) { + toast(`${spriteType} sprite saved`); + // Treat the saved state as the new baseline. + history = [srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)]; + historyIndex = 0; + updateHistoryButtons(); + if (saveCallback) saveCallback(); + } else { + const err = await res.json().catch(() => ({})); + toast(err.error || 'Save failed', true); + } + } catch (e) { + toast('Connection error', true); + } + saveBtn.disabled = false; saveBtn.textContent = 'SAVE'; + }, 'image/png'); + } + + // =================================================================== + // Keyboard shortcuts + // =================================================================== + function onKeyDown(e) { + if (!isOpen()) return; + // Don't hijack typing in inputs. + if (e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && e.target.type !== 'range') { + if (e.key === 'Escape') e.target.blur(); + return; + } + + const ctrl = e.ctrlKey || e.metaKey; + if (ctrl && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if (ctrl && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { + e.preventDefault(); redo(); return; + } + + switch (e.key) { + case 'Escape': if (confirmDiscard()) close(); break; + case 'b': case 'B': setTool('pencil'); break; + case 'e': case 'E': setTool('eraser'); break; + case 'i': case 'I': setTool('picker'); break; + case 'f': case 'F': setTool('fill'); break; + case 'g': case 'G': els.grid.checked = !els.grid.checked; showGrid = els.grid.checked; render(); break; + case '+': case '=': zoomAt(1.25, centerPoint()); break; + case '-': case '_': zoomAt(0.8, centerPoint()); break; + case '0': fitToScreen(); break; + case ' ': + if (!spaceDown) { spaceDown = true; updateCursor(); } + e.preventDefault(); + break; + } + } + + function onKeyUp(e) { + if (!isOpen()) return; + if (e.key === ' ') { spaceDown = false; updateCursor(); } + } + + // =================================================================== + // Helpers + // =================================================================== + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + + function hexToRgb(hex) { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + const n = parseInt(hex, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + + function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); + } + + // Reuse the admin toast if present, else fall back to a console message. + function toast(msg, isError) { + const el = document.getElementById('toast'); + if (!el) { return; } + el.textContent = msg; + el.classList.remove('hidden', 'error'); + if (isError) el.classList.add('error'); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.add('hidden'), 3000); + } + + // Expose public API + window.PixelEditor = { open, close }; +})();