From 5fabd035c211e896b8238ab5d79f20e3332fc0c1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 15:52:31 -0700 Subject: [PATCH] Add shape tools, select/move, reference overlay, and color tools to pixel editor Extends the in-app sprite editor with four feature sets: - Shape tools: line (L), rectangle (R), ellipse (O) with click-drag live preview and a FILL toggle for outline vs filled rect/ellipse. - Select & move: rectangular marquee (M) to select, drag-to-move via a floating layer, arrow-key nudge, and clipboard ops (Ctrl+C/X/V, Delete, Enter/Esc to commit). Undo/redo and save auto-bake any pending move. - Reference overlay: load any image as an adjustable-opacity backdrop to trace over (rendered under the artwork), with a show/hide toggle. - Color tools: brush opacity slider, persistent custom palette (localStorage; right-click to remove), and an auto-updating recent colors row. Cache-bust admin assets to ?v=20260609c so the update loads without a manual hard-refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- admin/admin.css | 47 ++++ admin/admin.html | 6 +- admin/pixel-editor.js | 567 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 550 insertions(+), 70 deletions(-) diff --git a/admin/admin.css b/admin/admin.css index f5d75ff..7e02dc8 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -901,6 +901,53 @@ body.pe-open { overflow: hidden; } .pe-readout span { color: #54fc54; } +/* Sidebar: opacity, palettes, reference */ +.pe-opacity-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.pe-opacity-group input[type="range"] { width: 100%; accent-color: #fcd444; } + +.pe-subhead { + font-family: 'Press Start 2P', monospace; + font-size: 7px; + color: #b4d4ec; + margin-top: 4px; + letter-spacing: 1px; +} + +.pe-mini-btn { + font-family: 'Press Start 2P', monospace; + font-size: 7px; + padding: 6px 4px; + border: 2px solid #5c94fc; + border-radius: 5px; + background: rgba(92, 148, 252, 0.15); + color: #fcfcfc; + cursor: pointer; + transition: all 0.12s; + text-align: center; +} +.pe-mini-btn:hover { background: rgba(92, 148, 252, 0.4); } + +#pe-custom-palette:empty::after, +#pe-recent-palette:empty::after { + content: '—'; + color: #555; + font-size: 11px; +} + +.pe-ref-controls { + display: flex; + flex-direction: column; + gap: 6px; +} +.pe-ref-row { display: flex; gap: 6px; } +.pe-ref-row .pe-mini-btn { flex: 1; } +.pe-ref-load { display: inline-block; } +.pe-ref-controls input[type="range"] { width: 100%; accent-color: #fcd444; } + /* 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); } diff --git a/admin/admin.html b/admin/admin.html index 196d28e..b867ea6 100644 --- a/admin/admin.html +++ b/admin/admin.html @@ -7,7 +7,7 @@ - + @@ -297,7 +297,7 @@ - - + + diff --git a/admin/pixel-editor.js b/admin/pixel-editor.js index 8d257d1..d33bc5c 100644 --- a/admin/pixel-editor.js +++ b/admin/pixel-editor.js @@ -4,10 +4,13 @@ // is POSTed to the existing /api/admin/sprites/{type} endpoint. // // Public API: -// PixelEditor.open(spriteType, { existingUrl, onSave }) +// PixelEditor.open(spriteType, { onSave }) // -// Tools: pencil, eraser, color picker, fill bucket, pan. -// Features: zoom/pan, undo/redo, grid overlay, load existing, export PNG. +// Tools: pencil, eraser, color picker, fill bucket, pan, +// line, rectangle, ellipse (outline/filled), rectangular select. +// Features: zoom/pan, undo/redo, grid overlay, brush size & opacity, +// custom + recent color palettes, reference-image overlay, +// selection move/cut/copy/paste/delete, load existing, export PNG. (function () { 'use strict'; @@ -20,6 +23,8 @@ 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 + const RECENT_LIMIT = 10; + const CUSTOM_KEY = 'pe-custom-palette'; // ----- Retro palette (SNES-style white/blue/red leaning) ----- const PALETTE = [ @@ -29,6 +34,8 @@ '#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc', ]; + const SHAPE_TOOLS = ['line', 'rect', 'ellipse']; + // ----- Module state ----- let root = null; // overlay element let els = {}; // cached DOM references @@ -40,7 +47,9 @@ let tool = 'pencil'; let brushSize = 1; + let brushOpacity = 1; // 0..1, applies to color tools (not eraser) let color = '#5c94fc'; + let shapeFill = false; let zoom = 1; let panX = 0, panY = 0; let showGrid = true; @@ -49,11 +58,29 @@ 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 drawing = false; // pencil/eraser stroke in progress + let drawingShape = false; // line/rect/ellipse drag in progress + let panning = false; // pan drag 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 + let panStart = null; // {cx,cy,panX,panY} at pan start + let shapeStart = null; // {x,y} where a shape drag began + let shapeSnapshot = null; // ImageData captured at shape drag start + + // Selection / move / clipboard + let selection = null; // {x,y,w,h} committed selection (source px) + let selDrag = null; // {mode:'new'|'move', sx,sy, ox,oy} + let floating = null; // {canvas,x,y,w,h} lifted/pasted pixels + let clipboard = null; // of copied pixels + + // Color palettes + let recentColors = []; + let customPalette = []; + + // Reference overlay + let refImage = null; + let refOpacity = 0.5; + let refVisible = true; // Undo/redo let history = []; @@ -86,6 +113,15 @@ + +
+ + + + +
+
+
@@ -114,11 +150,31 @@
+
+ OPACITY 100% + +
+ +
CUSTOM
+
+
RECENT
+
+
REFERENCE
+
+
+ + +
+ + +
POS --, --
ZOOM 100%
-
SIZE 500×333
+
SEL none
@@ -130,15 +186,24 @@ els.title = root.querySelector('#pe-title'); els.wrap = root.querySelector('#pe-canvas-wrap'); els.palette = root.querySelector('#pe-palette'); + els.customPalette = root.querySelector('#pe-custom-palette'); + els.recentPalette = root.querySelector('#pe-recent-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.selVal = root.querySelector('#pe-sel'); els.brush = root.querySelector('#pe-brush'); els.brushVal = root.querySelector('#pe-brush-val'); + els.fill = root.querySelector('#pe-fill'); + els.opacity = root.querySelector('#pe-opacity'); + els.opacityVal = root.querySelector('#pe-opacity-val'); els.grid = root.querySelector('#pe-grid'); els.undo = root.querySelector('#pe-undo'); els.redo = root.querySelector('#pe-redo'); + els.refFile = root.querySelector('#pe-ref-file'); + els.refShow = root.querySelector('#pe-ref-show'); + els.refOpacity = root.querySelector('#pe-ref-opacity'); viewCanvas = root.querySelector('#pe-view'); viewCtx = viewCanvas.getContext('2d'); @@ -149,32 +214,49 @@ srcCanvas.height = SPRITE_H; srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); - buildPalette(); + buildPalette(els.palette, PALETTE, false); wireEvents(); } - function buildPalette() { - els.palette.innerHTML = ''; - PALETTE.forEach(c => { + // Build a row of color swatches. `removable` enables right-click removal + // (used by the custom palette). + function buildPalette(container, colors, removable) { + container.innerHTML = ''; + colors.forEach(c => { const sw = document.createElement('button'); sw.className = 'pe-swatch'; sw.style.background = c; - sw.title = c; + sw.title = removable ? `${c} (right-click to remove)` : c; sw.addEventListener('click', () => setColor(c)); - els.palette.appendChild(sw); + if (removable) { + sw.addEventListener('contextmenu', (e) => { + e.preventDefault(); + customPalette = customPalette.filter(x => x !== c); + saveCustomPalette(); + buildCustomPalette(); + }); + } + container.appendChild(sw); }); } + function buildCustomPalette() { + buildPalette(els.customPalette, customPalette, true); + root.querySelector('#pe-custom-head').style.opacity = customPalette.length ? '1' : '0.5'; + } + + function buildRecentPalette() { + buildPalette(els.recentPalette, recentColors, false); + } + // =================================================================== // 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(); }); @@ -182,24 +264,39 @@ 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; }); + els.fill.addEventListener('change', () => { shapeFill = els.fill.checked; }); + els.opacity.addEventListener('input', () => { + brushOpacity = parseInt(els.opacity.value, 10) / 100; + els.opacityVal.textContent = els.opacity.value + '%'; + }); - // Color input els.colorInput.addEventListener('input', () => setColor(els.colorInput.value)); + root.querySelector('#pe-add-swatch').addEventListener('click', addCurrentColorToPalette); + + // Reference overlay + els.refFile.addEventListener('change', (e) => { + const f = e.target.files[0]; + if (f) loadRefFromFile(f); + e.target.value = ''; + }); + root.querySelector('#pe-ref-clear').addEventListener('click', clearRef); + els.refShow.addEventListener('change', () => { refVisible = els.refShow.checked; render(); }); + els.refOpacity.addEventListener('input', () => { + refOpacity = parseInt(els.refOpacity.value, 10) / 100; + render(); + }); // Canvas pointer events viewCanvas.addEventListener('pointerdown', onPointerDown); @@ -209,11 +306,9 @@ 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(); } }); } @@ -227,16 +322,28 @@ saveCallback = opts.onSave || null; els.title.textContent = 'PIXEL EDITOR — ' + type; - // Reset state + // Reset transient state zoom = 1; panX = 0; panY = 0; history = []; historyIndex = -1; + selection = null; selDrag = null; floating = null; + drawing = drawingShape = false; shapeSnapshot = null; + refImage = null; refVisible = true; + recentColors = []; + brushOpacity = 1; + shapeFill = false; + els.opacity.value = 100; els.opacityVal.textContent = '100%'; + els.fill.checked = false; + els.refShow.checked = true; + loadCustomPalette(); + buildCustomPalette(); + buildRecentPalette(); setTool('pencil'); + updateSelReadout(); 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(); @@ -255,8 +362,7 @@ } function confirmDiscard() { - // Only warn if there is edit history beyond the baseline. - if (historyIndex > 0) { + if (historyIndex > 0 || floating) { return window.confirm('Discard unsaved changes and close the editor?'); } return true; @@ -270,7 +376,6 @@ 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'); } @@ -278,7 +383,6 @@ 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(); @@ -287,6 +391,7 @@ } function clearCanvas() { + floating = null; selection = null; updateSelReadout(); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); pushHistory(); render(); @@ -296,6 +401,10 @@ // Tool / color selection // =================================================================== function setTool(t) { + if (t !== tool) { + stampFloating(); // commit any lifted pixels before switching + if (t !== 'select') { selection = null; updateSelReadout(); } + } tool = t; root.querySelectorAll('.pe-tool').forEach(b => { b.classList.toggle('active', b.dataset.tool === t); @@ -311,13 +420,69 @@ } } + function recordColor(c) { + c = c.toLowerCase(); + recentColors = recentColors.filter(x => x !== c); + recentColors.unshift(c); + if (recentColors.length > RECENT_LIMIT) recentColors.length = RECENT_LIMIT; + buildRecentPalette(); + } + + function addCurrentColorToPalette() { + const c = color.toLowerCase(); + if (!customPalette.includes(c)) { + customPalette.push(c); + saveCustomPalette(); + buildCustomPalette(); + toast('Color saved to palette'); + } + } + + function loadCustomPalette() { + try { customPalette = JSON.parse(localStorage.getItem(CUSTOM_KEY) || '[]'); } + catch (e) { customPalette = []; } + } + function saveCustomPalette() { + try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(customPalette)); } catch (e) { /* ignore */ } + } + function updateCursor() { let cur = 'crosshair'; if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab'; else if (tool === 'picker') cur = 'cell'; + else if (tool === 'select') cur = 'crosshair'; viewCanvas.style.cursor = cur; } + function updateSelReadout() { + if (floating) els.selVal.textContent = `${floating.w}×${floating.h} (moving)`; + else if (selection) els.selVal.textContent = `${selection.w}×${selection.h}`; + else els.selVal.textContent = 'none'; + } + + // =================================================================== + // Reference overlay + // =================================================================== + function loadRefFromFile(file) { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + refImage = img; + refVisible = true; + els.refShow.checked = true; + render(); + URL.revokeObjectURL(url); + toast('Reference loaded'); + }; + img.onerror = () => { toast('Could not load reference image', true); URL.revokeObjectURL(url); }; + img.src = url; + } + + function clearRef() { + refImage = null; + render(); + } + // =================================================================== // Coordinate mapping // =================================================================== @@ -336,6 +501,9 @@ return { cx: viewCanvas.width / 2, cy: viewCanvas.height / 2 }; } + function isShapeTool(t) { return SHAPE_TOOLS.indexOf(t) !== -1; } + function pointInRect(p, r) { return r && p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h; } + // =================================================================== // Pointer handlers // =================================================================== @@ -352,14 +520,26 @@ return; } - if (tool === 'picker') { - pickColor(p.x, p.y); - return; - } + if (tool === 'picker') { pickColor(p.x, p.y); return; } + + if (tool === 'select') { onSelectDown(p); return; } + + // All remaining tools draw onto the canvas — commit any floating first. + stampFloating(); if (tool === 'fill') { floodFill(p.x, p.y); pushHistory(); + recordColor(color); + render(); + return; + } + + if (isShapeTool(tool)) { + shapeStart = { x: p.x, y: p.y }; + shapeSnapshot = srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H); + drawingShape = true; + rasterShape(tool, p.x, p.y, p.x, p.y); render(); return; } @@ -372,13 +552,30 @@ render(); } + function onSelectDown(p) { + if (floating) { + if (pointInRect(p, floating)) { + selDrag = { mode: 'move', sx: p.x, sy: p.y, ox: floating.x, oy: floating.y }; + return; + } + stampFloating(); // clicked outside → drop it in place + } + if (selection && pointInRect(p, selection)) { + liftSelection(); // begin moving the committed selection + selDrag = { mode: 'move', sx: p.x, sy: p.y, ox: floating.x, oy: floating.y }; + return; + } + // Begin a new selection rectangle. + selDrag = { mode: 'new', sx: p.x, sy: p.y }; + selection = { x: p.x, y: p.y, w: 1, h: 1 }; + updateSelReadout(); + 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 = '--, --'; - } + els.pos.textContent = (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H) + ? `${p.x}, ${p.y}` : '--, --'; if (panning && panStart) { panX = panStart.panX + (e.clientX - panStart.cx); @@ -387,8 +584,31 @@ return; } + if (selDrag) { + if (selDrag.mode === 'new') { + const x0 = clamp(selDrag.sx, 0, SPRITE_W - 1), y0 = clamp(selDrag.sy, 0, SPRITE_H - 1); + const x1 = clamp(p.x, 0, SPRITE_W - 1), y1 = clamp(p.y, 0, SPRITE_H - 1); + selection = { + x: Math.min(x0, x1), y: Math.min(y0, y1), + w: Math.abs(x1 - x0) + 1, h: Math.abs(y1 - y0) + 1 + }; + } else if (floating) { + floating.x = selDrag.ox + (p.x - selDrag.sx); + floating.y = selDrag.oy + (p.y - selDrag.sy); + } + updateSelReadout(); + render(); + return; + } + + if (drawingShape) { + srcCtx.putImageData(shapeSnapshot, 0, 0); + rasterShape(tool, shapeStart.x, shapeStart.y, p.x, p.y); + 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 }; @@ -397,10 +617,25 @@ } function onPointerUp() { + if (selDrag) { + if (selDrag.mode === 'new' && selection && (selection.w < 1 || selection.h < 1)) { + selection = null; + } + selDrag = null; + updateSelReadout(); + render(); + } + if (drawingShape) { + drawingShape = false; + shapeSnapshot = null; + pushHistory(); + recordColor(color); + } if (drawing) { drawing = false; lastDraw = null; - pushHistory(); // one undo step per completed stroke + pushHistory(); + if (tool !== 'eraser') recordColor(color); } if (panning) { panning = false; @@ -412,8 +647,7 @@ 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); + zoomAt(e.deltaY < 0 ? 1.15 : 0.87, { cx: e.clientX - rect.left, cy: e.clientY - rect.top }); } // =================================================================== @@ -425,12 +659,14 @@ if (tool === 'eraser') { srcCtx.clearRect(x0, y0, brushSize, brushSize); } else { + srcCtx.globalAlpha = brushOpacity; srcCtx.fillStyle = color; srcCtx.fillRect(x0, y0, brushSize, brushSize); + srcCtx.globalAlpha = 1; } } - // Bresenham line between two source pixels, stamping the brush along it. + // Bresenham line, 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; @@ -444,6 +680,66 @@ } } + // ----- Shape rasterizers (commit into srcCtx) ----- + function rasterShape(t, x0, y0, x1, y1) { + if (t === 'line') lineApply(x0, y0, x1, y1); + else if (t === 'rect') rasterRect(x0, y0, x1, y1, shapeFill); + else if (t === 'ellipse') rasterEllipse(x0, y0, x1, y1, shapeFill); + } + + function rasterRect(x0, y0, x1, y1, fill) { + const left = Math.min(x0, x1), right = Math.max(x0, x1); + const top = Math.min(y0, y1), bottom = Math.max(y0, y1); + if (fill) { + srcCtx.globalAlpha = brushOpacity; + srcCtx.fillStyle = color; + srcCtx.fillRect(left, top, right - left + 1, bottom - top + 1); + srcCtx.globalAlpha = 1; + } else { + lineApply(left, top, right, top); + lineApply(left, bottom, right, bottom); + lineApply(left, top, left, bottom); + lineApply(right, top, right, bottom); + } + } + + function rasterEllipse(x0, y0, x1, y1, fill) { + const left = Math.min(x0, x1), right = Math.max(x0, x1); + const top = Math.min(y0, y1), bottom = Math.max(y0, y1); + const a = (right - left) / 2, b = (bottom - top) / 2; + const cx = (left + right) / 2, cy = (top + bottom) / 2; + if (a < 0.5 || b < 0.5) { lineApply(left, top, right, bottom); return; } + + if (fill) { + srcCtx.globalAlpha = brushOpacity; + srcCtx.fillStyle = color; + for (let y = top; y <= bottom; y++) { + const ny = (y - cy) / b; + if (Math.abs(ny) > 1) continue; + const dx = a * Math.sqrt(1 - ny * ny); + const xl = Math.round(cx - dx), xr = Math.round(cx + dx); + srcCtx.fillRect(xl, y, xr - xl + 1, 1); + } + srcCtx.globalAlpha = 1; + } else { + // Scan rows then columns so the 1px outline has no gaps. + for (let y = top; y <= bottom; y++) { + const ny = (y - cy) / b; + if (Math.abs(ny) > 1) continue; + const dx = a * Math.sqrt(1 - ny * ny); + applyBrush(Math.round(cx - dx), y); + applyBrush(Math.round(cx + dx), y); + } + for (let x = left; x <= right; x++) { + const nx = (x - cx) / a; + if (Math.abs(nx) > 1) continue; + const dy = b * Math.sqrt(1 - nx * nx); + applyBrush(x, Math.round(cy - dy)); + applyBrush(x, Math.round(cy + dy)); + } + } + } + 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; @@ -462,7 +758,6 @@ 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; @@ -482,13 +777,104 @@ srcCtx.putImageData(img, 0, 0); } + // =================================================================== + // Selection / move / clipboard + // =================================================================== + function clampSelection() { + if (!selection) return; + const x = clamp(selection.x, 0, SPRITE_W - 1); + const y = clamp(selection.y, 0, SPRITE_H - 1); + selection = { + x, y, + w: clamp(selection.w, 1, SPRITE_W - x), + h: clamp(selection.h, 1, SPRITE_H - y), + }; + } + + // Lift the selected pixels off the canvas into a floating layer. + function liftSelection() { + clampSelection(); + const { x, y, w, h } = selection; + const c = document.createElement('canvas'); + c.width = w; c.height = h; + c.getContext('2d').drawImage(srcCanvas, x, y, w, h, 0, 0, w, h); + srcCtx.clearRect(x, y, w, h); + floating = { canvas: c, x, y, w, h }; + selection = null; + updateSelReadout(); + } + + // Composite the floating layer back onto the canvas and record history. + function stampFloating() { + if (!floating) return; + srcCtx.drawImage(floating.canvas, floating.x, floating.y); + floating = null; + updateSelReadout(); + pushHistory(); + render(); + } + + function copySelection() { + if (!selection) { toast('Nothing selected', true); return; } + clampSelection(); + const { x, y, w, h } = selection; + const c = document.createElement('canvas'); + c.width = w; c.height = h; + c.getContext('2d').drawImage(srcCanvas, x, y, w, h, 0, 0, w, h); + clipboard = c; + toast(`Copied ${w}×${h}`); + } + + function cutSelection() { + if (!selection) { toast('Nothing selected', true); return; } + copySelection(); + const { x, y, w, h } = selection; + srcCtx.clearRect(x, y, w, h); + selection = null; + updateSelReadout(); + pushHistory(); + render(); + } + + function pasteClipboard() { + if (!clipboard) { toast('Clipboard empty', true); return; } + stampFloating(); + const c = document.createElement('canvas'); + c.width = clipboard.width; c.height = clipboard.height; + c.getContext('2d').drawImage(clipboard, 0, 0); + const x = selection ? selection.x : 0; + const y = selection ? selection.y : 0; + floating = { canvas: c, x, y, w: c.width, h: c.height }; + setTool('select'); + updateSelReadout(); + render(); + toast('Pasted — drag to position, Enter to place'); + } + + function deleteSelection() { + if (floating) { floating = null; updateSelReadout(); pushHistory(); render(); return; } + if (!selection) return; + clampSelection(); + const { x, y, w, h } = selection; + srcCtx.clearRect(x, y, w, h); + pushHistory(); + render(); + } + + function nudgeFloating(dx, dy) { + if (!floating && selection) liftSelection(); + if (floating) { + floating.x += dx; floating.y += dy; + render(); + } + } + // =================================================================== // 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; @@ -521,13 +907,27 @@ 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. + // Reference overlay sits under the sprite so you can trace over it. + if (refImage && refVisible) { + viewCtx.globalAlpha = refOpacity; + viewCtx.imageSmoothingEnabled = false; + viewCtx.drawImage(refImage, panX, panY, dw, dh); + viewCtx.globalAlpha = 1; + } + viewCtx.imageSmoothingEnabled = false; viewCtx.drawImage(srcCanvas, panX, panY, dw, dh); + // Floating (moved/pasted) pixels render above the sprite. + if (floating) { + viewCtx.imageSmoothingEnabled = false; + viewCtx.drawImage(floating.canvas, + panX + floating.x * zoom, panY + floating.y * zoom, + floating.w * zoom, floating.h * zoom); + } + // Artboard border. viewCtx.strokeStyle = '#5c94fc'; viewCtx.lineWidth = 1; @@ -535,12 +935,23 @@ if (showGrid && zoom >= GRID_MIN_ZOOM) drawGrid(dw, dh); + if (selection) drawMarquee(selection, '#fcd444'); + if (floating) drawMarquee(floating, '#54fc54'); + els.zoomVal.textContent = Math.round(zoom * 100) + '%'; } + function drawMarquee(r, stroke) { + viewCtx.save(); + viewCtx.setLineDash([4, 3]); + viewCtx.strokeStyle = stroke; + viewCtx.lineWidth = 1; + viewCtx.strokeRect(panX + r.x * zoom + 0.5, panY + r.y * zoom + 0.5, r.w * zoom, r.h * zoom); + viewCtx.restore(); + } + 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); @@ -578,35 +989,39 @@ // 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); + function restore(index) { + // Pending floating/selection are invalidated by an undo/redo. + floating = null; selection = null; selDrag = null; + updateSelReadout(); + srcCtx.putImageData(history[index], 0, 0); render(); updateHistoryButtons(); } + function undo() { + stampFloating(); + if (historyIndex <= 0) return; + historyIndex--; + restore(historyIndex); + } + function redo() { if (historyIndex >= history.length - 1) return; historyIndex++; - srcCtx.putImageData(history[historyIndex], 0, 0); - render(); - updateHistoryButtons(); + restore(historyIndex); } function updateHistoryButtons() { - els.undo.disabled = historyIndex <= 0; + els.undo.disabled = historyIndex <= 0 && !floating; els.redo.disabled = historyIndex >= history.length - 1; } @@ -615,6 +1030,7 @@ // =================================================================== function save() { if (!spriteType) return; + stampFloating(); // bake any floating pixels before exporting const saveBtn = root.querySelector('#pe-save'); saveBtn.disabled = true; saveBtn.textContent = 'SAVING…'; @@ -627,12 +1043,9 @@ const fd = new FormData(); fd.append('file', blob, `${spriteType}.png`); try { - const res = await fetch(`/api/admin/sprites/${spriteType}`, { - method: 'POST', body: fd, - }); + 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(); @@ -653,28 +1066,47 @@ // =================================================================== function onKeyDown(e) { if (!isOpen()) return; - // Don't hijack typing in inputs. + // Don't hijack typing in text/number 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; + if (ctrl) { + const k = e.key.toLowerCase(); + if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); redo(); return; } + if (k === 'c') { e.preventDefault(); copySelection(); return; } + if (k === 'x') { e.preventDefault(); cutSelection(); return; } + if (k === 'v') { e.preventDefault(); pasteClipboard(); return; } + return; } switch (e.key) { - case 'Escape': if (confirmDiscard()) close(); break; + case 'Escape': + if (floating) { stampFloating(); } + else if (selection) { selection = null; updateSelReadout(); render(); } + else if (confirmDiscard()) close(); + break; + case 'Enter': if (floating) stampFloating(); break; + case 'Delete': case 'Backspace': e.preventDefault(); deleteSelection(); 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 'l': case 'L': setTool('line'); break; + case 'r': case 'R': setTool('rect'); break; + case 'o': case 'O': setTool('ellipse'); break; + case 'm': case 'M': setTool('select'); 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 'ArrowLeft': if (selOrFloat()) { e.preventDefault(); nudgeFloating(-1, 0); } break; + case 'ArrowRight': if (selOrFloat()) { e.preventDefault(); nudgeFloating(1, 0); } break; + case 'ArrowUp': if (selOrFloat()) { e.preventDefault(); nudgeFloating(0, -1); } break; + case 'ArrowDown': if (selOrFloat()) { e.preventDefault(); nudgeFloating(0, 1); } break; case ' ': if (!spaceDown) { spaceDown = true; updateCursor(); } e.preventDefault(); @@ -682,6 +1114,8 @@ } } + function selOrFloat() { return tool === 'select' && (selection || floating); } + function onKeyUp(e) { if (!isOpen()) return; if (e.key === ' ') { spaceDown = false; updateCursor(); } @@ -703,10 +1137,9 @@ 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; } + if (!el) return; el.textContent = msg; el.classList.remove('hidden', 'error'); if (isError) el.classList.add('error'); -- 2.39.5