11 Commits

Author SHA1 Message Date
root 7a80057cb5 Add receiver health UX, fix restart crash, add interface scan selector
Receiver management improvements for the admin Receivers tab:

- Per-receiver health: server now tracks each connection's state
  (connecting/connected/down), message count, and last-message time, exposed
  via new GET /api/admin/receiver-status and POST /api/admin/test-receivers.
- Receivers tab redesign: live "Active Receivers" panel with colored
  indicators (green=receiving, yellow=connected/no data, red=unreachable),
  a clear select -> SAVE & APPLY -> auto-test flow, and selectable scan
  results with a "USE SELECTED RECEIVERS" action.
- Network scan interface selector: scan a chosen interface/subnet instead of
  every local subnet. Oversized subnets (>/20, e.g. a docker /16) are disabled
  in the UI and rejected server-side, eliminating slow/hanging scans.

Critical fix — server crash on receiver restart:

- Receiver connection tasks were awaited inside main()'s top-level
  asyncio.gather. Restarting receivers cancels those tasks, and the resulting
  CancelledError propagated into the gather and killed the whole server
  process (every action after a restart then failed). Receiver tasks now run
  as independent background tasks; restart cancels and awaits them with
  return_exceptions=True so cancellation can never tear down the server.

Also cache-bust admin.css/admin.js/pixel-editor.js (?v=) so updated assets
load without a manual hard-refresh, and surface a clear message instead of a
silent "Loading…" when the status endpoint is unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:49:45 -07:00
root 214ee8dd90 Add in-app pixel editor for sprites in admin UI
Build a dependency-free Canvas2D pixel editor integrated into the admin
Sprites tab. Each sprite card gains an EDIT button that opens a full-screen
editor for the sprite at its native 500x333 resolution.

Tools: pencil (1-8px brush, Bresenham interpolation), eraser, color picker
(palette + native input + pick-from-canvas), fill bucket (flood fill), pan.
Features: cursor-centered zoom, space-drag pan, undo/redo (snapshot per
stroke), pixel grid overlay, transparency checkerboard, load existing sprite,
and keyboard shortcuts (B/E/I/F, Space, +/-/0, Ctrl+Z/Y, G, Esc).

Export reuses the existing POST /api/admin/sprites/{type} upload endpoint via
srcCanvas.toBlob, so no server changes are required.

Closes #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:39:31 -07:00
root 38043d7d1f Add Docker Compose and Podman support for containerized deployment
Adds Dockerfile, docker-compose.yml, and .dockerignore to enable
running ADS-Bit in a container with host networking for LAN receiver
auto-scanning. Bind mounts persist config, sprites, and backgrounds
across container recreation. Updates README with usage instructions.

Closes #4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:52:57 -07:00
root 7af9789db7 Add download buttons for sprites and theme backgrounds
Allow users to download existing sprite and background assets from the
admin panel, useful for editing externally and re-uploading or backing
up customizations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:47:20 -07:00
root 9f341a5b14 Add host network info display and custom subnet scan to Receivers tab
Shows the server's network interfaces (IP, subnet) in the admin Receivers
tab so users know which networks are being scanned. Adds an optional subnet
input field to scan arbitrary networks (e.g. VLANs the server can route to
but isn't directly attached to), with validation and a /20 size cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:06:31 -07:00
root 3391523d45 Add admin theme management: rename, upload, and create themes
Adds a THEMES tab to the admin panel with per-theme cards showing
4 directional previews, editable display names, per-direction PNG
upload, and new theme creation. Display names are stored in
config.json theme_names map rather than renaming filesystem folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 10:50:40 -07:00
root 23d469d008 Add balloon, glider, and UAV sprites with admin sprite management
New dedicated pixel art sprites for ADS-B emitter categories B2
(lighter-than-air → balloon), B1 (glider/sailplane → glider), and
B6 (UAV → uav), replacing the previous fallback to smallProp. Adds
an admin Sprites tab for previewing and uploading replacement PNGs
for all 9 aircraft sprite types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:44:46 -07:00
root a3fa38b740 Add ADS-B emitter category support via dump1090 JSON API
Poll dump1090's aircraft.json endpoint to get real transponder-reported
aircraft categories (A1=Light, A3=Large, A5=Heavy, A7=Rotorcraft, etc.)
instead of relying solely on heuristics. The SBS protocol on port 30003
doesn't include this field, but the JSON API on port 80 does. Categories
are polled every 5 seconds and merged into flight data. Client uses
emitter category when available, falling back to heuristic classification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:12:08 -07:00
root c996ade142 Fix aircraft categorization misclassifying Cessnas as helicopters
N-prefix callsigns (US general aviation) were incorrectly matching the
helicopter detection rules due to 'N' in the helicopter callsign list
and a broken .some() condition. Speed/altitude heuristics were also too
broad, and zero-value speed data (not yet received) triggered false
matches. Types are now re-evaluated as better data arrives instead of
being locked to the first guess.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:00:15 -07:00
root 2a2c29de29 Rename project references from Pixel View to ADS-Bit
Rename pixel-view.js to ads-bit.js, rename PixelADSB class to ADSBit,
and update all documentation and code comments to use ADS-Bit branding
consistently throughout the project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 08:54:59 -07:00
root 22a3078af9 Add admin panel, authentication, and first-run setup wizard for v1.0
Implements credentialed admin panel and multi-step setup wizard so new
users can configure location, receivers, and preferences without editing
JSON files. Adds bcrypt password auth with session cookies, setup lockout
after first run, and expanded config schema with backward compatibility.

New files: admin/{html,css,js}, setup/{html,css,js}
Modified: server.py (auth, setup, admin APIs), index.html (dynamic title),
pixel-view.js (site/display config), requirements.txt (bcrypt),
config.json.example (full schema)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 08:35:07 -07:00
5 changed files with 71 additions and 551 deletions
+2 -2
View File
@@ -102,8 +102,8 @@ ADS-Bit uses a first-run setup wizard to configure your installation. You can al
"receiver_port": 30003, "receiver_port": 30003,
"location": { "location": {
"name": "My Location", "name": "My Location",
"lat": 36.2788, "lat": 0.0,
"lon": -115.2283 "lon": 0.0
}, },
"web_port": 2001, "web_port": 2001,
"theme": "desert" "theme": "desert"
-47
View File
@@ -901,53 +901,6 @@ body.pe-open { overflow: hidden; }
.pe-readout span { color: #54fc54; } .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 card EDIT button uses primary accent */
.sprite-edit-btn { border-color: #fcd444; color: #fcd444; } .sprite-edit-btn { border-color: #fcd444; color: #fcd444; }
.sprite-edit-btn:hover { background: rgba(252, 212, 68, 0.3); } .sprite-edit-btn:hover { background: rgba(252, 212, 68, 0.3); }
+3 -3
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/admin/admin.css?v=20260609c"> <link rel="stylesheet" href="/admin/admin.css?v=20260609b">
</head> </head>
<body> <body>
<!-- Login Screen --> <!-- Login Screen -->
@@ -297,7 +297,7 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
</div> </div>
<script src="/admin/pixel-editor.js?v=20260609c"></script> <script src="/admin/pixel-editor.js?v=20260609b"></script>
<script src="/admin/admin.js?v=20260609c"></script> <script src="/admin/admin.js?v=20260609b"></script>
</body> </body>
</html> </html>
+66 -499
View File
@@ -4,13 +4,10 @@
// is POSTed to the existing /api/admin/sprites/{type} endpoint. // is POSTed to the existing /api/admin/sprites/{type} endpoint.
// //
// Public API: // Public API:
// PixelEditor.open(spriteType, { onSave }) // PixelEditor.open(spriteType, { existingUrl, onSave })
// //
// Tools: pencil, eraser, color picker, fill bucket, pan, // Tools: pencil, eraser, color picker, fill bucket, pan.
// line, rectangle, ellipse (outline/filled), rectangular select. // Features: zoom/pan, undo/redo, grid overlay, load existing, export PNG.
// 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 () { (function () {
'use strict'; 'use strict';
@@ -23,8 +20,6 @@
const MAX_ZOOM = 40; const MAX_ZOOM = 40;
const HISTORY_LIMIT = 40; // max undo snapshots (~666 KB each) const HISTORY_LIMIT = 40; // max undo snapshots (~666 KB each)
const GRID_MIN_ZOOM = 6; // only show pixel grid when zoomed in enough 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) ----- // ----- Retro palette (SNES-style white/blue/red leaning) -----
const PALETTE = [ const PALETTE = [
@@ -34,8 +29,6 @@
'#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc', '#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc',
]; ];
const SHAPE_TOOLS = ['line', 'rect', 'ellipse'];
// ----- Module state ----- // ----- Module state -----
let root = null; // overlay element let root = null; // overlay element
let els = {}; // cached DOM references let els = {}; // cached DOM references
@@ -47,9 +40,7 @@
let tool = 'pencil'; let tool = 'pencil';
let brushSize = 1; let brushSize = 1;
let brushOpacity = 1; // 0..1, applies to color tools (not eraser)
let color = '#5c94fc'; let color = '#5c94fc';
let shapeFill = false;
let zoom = 1; let zoom = 1;
let panX = 0, panY = 0; let panX = 0, panY = 0;
let showGrid = true; let showGrid = true;
@@ -58,29 +49,11 @@
let saveCallback = null; let saveCallback = null;
// Pointer interaction state // Pointer interaction state
let drawing = false; // pencil/eraser stroke in progress let drawing = false; // a draw stroke is in progress
let drawingShape = false; // line/rect/ellipse drag in progress let panning = false; // a pan drag is in progress
let panning = false; // pan drag in progress
let spaceDown = false; // hold space to temporarily pan let spaceDown = false; // hold space to temporarily pan
let lastDraw = null; // last source pixel during a stroke {x,y} let lastDraw = null; // last source pixel during a stroke {x,y}
let panStart = null; // {cx,cy,panX,panY} at pan start let panStart = null; // {x,y,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; // <canvas> of copied pixels
// Color palettes
let recentColors = [];
let customPalette = [];
// Reference overlay
let refImage = null;
let refOpacity = 0.5;
let refVisible = true;
// Undo/redo // Undo/redo
let history = []; let history = [];
@@ -113,15 +86,6 @@
<button class="pe-tool" data-tool="eraser" title="Eraser (E)">⌫</button> <button class="pe-tool" data-tool="eraser" title="Eraser (E)">⌫</button>
<button class="pe-tool" data-tool="picker" title="Color picker (I)">⊙</button> <button class="pe-tool" data-tool="picker" title="Color picker (I)">⊙</button>
<button class="pe-tool" data-tool="fill" title="Fill bucket (F)">▣</button> <button class="pe-tool" data-tool="fill" title="Fill bucket (F)">▣</button>
</div>
<div class="pe-tool-group">
<button class="pe-tool" data-tool="line" title="Line (L)"></button>
<button class="pe-tool" data-tool="rect" title="Rectangle (R)">▭</button>
<button class="pe-tool" data-tool="ellipse" title="Ellipse (O)">◯</button>
<label class="pe-check" title="Fill shapes"><input type="checkbox" id="pe-fill"> FILL</label>
</div>
<div class="pe-tool-group">
<button class="pe-tool" data-tool="select" title="Select & move (M)">⬚</button>
<button class="pe-tool" data-tool="pan" title="Pan (hold Space)">✥</button> <button class="pe-tool" data-tool="pan" title="Pan (hold Space)">✥</button>
</div> </div>
<div class="pe-tool-group"> <div class="pe-tool-group">
@@ -150,31 +114,11 @@
<div class="pe-swatch-large" id="pe-current-swatch"></div> <div class="pe-swatch-large" id="pe-current-swatch"></div>
<input type="color" id="pe-color-input" value="#5c94fc"> <input type="color" id="pe-color-input" value="#5c94fc">
</div> </div>
<div class="pe-opacity-group">
<span class="pe-tool-label">OPACITY <span id="pe-opacity-val">100%</span></span>
<input type="range" id="pe-opacity" min="10" max="100" value="100">
</div>
<div class="pe-palette" id="pe-palette"></div> <div class="pe-palette" id="pe-palette"></div>
<button class="pe-mini-btn" id="pe-add-swatch" title="Save current color to your custom palette">+ SAVE COLOR</button>
<div class="pe-subhead" id="pe-custom-head">CUSTOM</div>
<div class="pe-palette" id="pe-custom-palette"></div>
<div class="pe-subhead">RECENT</div>
<div class="pe-palette" id="pe-recent-palette"></div>
<div class="pe-subhead">REFERENCE</div>
<div class="pe-ref-controls">
<div class="pe-ref-row">
<label class="pe-mini-btn pe-ref-load">LOAD
<input type="file" id="pe-ref-file" accept="image/*" hidden>
</label>
<button class="pe-mini-btn" id="pe-ref-clear">CLEAR</button>
</div>
<label class="pe-check"><input type="checkbox" id="pe-ref-show" checked> SHOW</label>
<input type="range" id="pe-ref-opacity" min="5" max="100" value="50" title="Reference opacity">
</div>
<div class="pe-readout"> <div class="pe-readout">
<div>POS <span id="pe-pos">--, --</span></div> <div>POS <span id="pe-pos">--, --</span></div>
<div>ZOOM <span id="pe-zoom-val">100%</span></div> <div>ZOOM <span id="pe-zoom-val">100%</span></div>
<div>SEL <span id="pe-sel">none</span></div> <div>SIZE 500×333</div>
</div> </div>
</div> </div>
</div> </div>
@@ -186,24 +130,15 @@
els.title = root.querySelector('#pe-title'); els.title = root.querySelector('#pe-title');
els.wrap = root.querySelector('#pe-canvas-wrap'); els.wrap = root.querySelector('#pe-canvas-wrap');
els.palette = root.querySelector('#pe-palette'); 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.currentSwatch = root.querySelector('#pe-current-swatch');
els.colorInput = root.querySelector('#pe-color-input'); els.colorInput = root.querySelector('#pe-color-input');
els.pos = root.querySelector('#pe-pos'); els.pos = root.querySelector('#pe-pos');
els.zoomVal = root.querySelector('#pe-zoom-val'); els.zoomVal = root.querySelector('#pe-zoom-val');
els.selVal = root.querySelector('#pe-sel');
els.brush = root.querySelector('#pe-brush'); els.brush = root.querySelector('#pe-brush');
els.brushVal = root.querySelector('#pe-brush-val'); 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.grid = root.querySelector('#pe-grid');
els.undo = root.querySelector('#pe-undo'); els.undo = root.querySelector('#pe-undo');
els.redo = root.querySelector('#pe-redo'); 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'); viewCanvas = root.querySelector('#pe-view');
viewCtx = viewCanvas.getContext('2d'); viewCtx = viewCanvas.getContext('2d');
@@ -214,49 +149,32 @@
srcCanvas.height = SPRITE_H; srcCanvas.height = SPRITE_H;
srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
buildPalette(els.palette, PALETTE, false); buildPalette();
wireEvents(); wireEvents();
} }
// Build a row of color swatches. `removable` enables right-click removal function buildPalette() {
// (used by the custom palette). els.palette.innerHTML = '';
function buildPalette(container, colors, removable) { PALETTE.forEach(c => {
container.innerHTML = '';
colors.forEach(c => {
const sw = document.createElement('button'); const sw = document.createElement('button');
sw.className = 'pe-swatch'; sw.className = 'pe-swatch';
sw.style.background = c; sw.style.background = c;
sw.title = removable ? `${c} (right-click to remove)` : c; sw.title = c;
sw.addEventListener('click', () => setColor(c)); sw.addEventListener('click', () => setColor(c));
if (removable) { els.palette.appendChild(sw);
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 // Event wiring
// =================================================================== // ===================================================================
function wireEvents() { function wireEvents() {
// Tool buttons
root.querySelectorAll('.pe-tool').forEach(btn => { root.querySelectorAll('.pe-tool').forEach(btn => {
btn.addEventListener('click', () => setTool(btn.dataset.tool)); btn.addEventListener('click', () => setTool(btn.dataset.tool));
}); });
// Header actions
root.querySelector('#pe-close').addEventListener('click', () => { root.querySelector('#pe-close').addEventListener('click', () => {
if (confirmDiscard()) close(); if (confirmDiscard()) close();
}); });
@@ -264,39 +182,24 @@
root.querySelector('#pe-load').addEventListener('click', () => loadExisting(true)); root.querySelector('#pe-load').addEventListener('click', () => loadExisting(true));
root.querySelector('#pe-clear').addEventListener('click', clearCanvas); root.querySelector('#pe-clear').addEventListener('click', clearCanvas);
// Zoom controls
root.querySelector('#pe-zoom-in').addEventListener('click', () => zoomAt(1.25, centerPoint())); 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-out').addEventListener('click', () => zoomAt(0.8, centerPoint()));
root.querySelector('#pe-zoom-fit').addEventListener('click', fitToScreen); root.querySelector('#pe-zoom-fit').addEventListener('click', fitToScreen);
// Undo/redo
els.undo.addEventListener('click', undo); els.undo.addEventListener('click', undo);
els.redo.addEventListener('click', redo); els.redo.addEventListener('click', redo);
// Grid + brush
els.grid.addEventListener('change', () => { showGrid = els.grid.checked; render(); }); els.grid.addEventListener('change', () => { showGrid = els.grid.checked; render(); });
els.brush.addEventListener('input', () => { els.brush.addEventListener('input', () => {
brushSize = parseInt(els.brush.value, 10); brushSize = parseInt(els.brush.value, 10);
els.brushVal.textContent = brushSize; 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)); 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 // Canvas pointer events
viewCanvas.addEventListener('pointerdown', onPointerDown); viewCanvas.addEventListener('pointerdown', onPointerDown);
@@ -306,9 +209,11 @@
viewCanvas.addEventListener('wheel', onWheel, { passive: false }); viewCanvas.addEventListener('wheel', onWheel, { passive: false });
viewCanvas.addEventListener('contextmenu', e => e.preventDefault()); viewCanvas.addEventListener('contextmenu', e => e.preventDefault());
// Keyboard shortcuts (only active while editor is open)
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp); document.addEventListener('keyup', onKeyUp);
// Keep the view sized to its container
window.addEventListener('resize', () => { if (isOpen()) { resizeView(); render(); } }); window.addEventListener('resize', () => { if (isOpen()) { resizeView(); render(); } });
} }
@@ -322,28 +227,16 @@
saveCallback = opts.onSave || null; saveCallback = opts.onSave || null;
els.title.textContent = 'PIXEL EDITOR — ' + type; els.title.textContent = 'PIXEL EDITOR — ' + type;
// Reset transient state // Reset state
zoom = 1; panX = 0; panY = 0; zoom = 1; panX = 0; panY = 0;
history = []; historyIndex = -1; 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'); setTool('pencil');
updateSelReadout();
srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H);
root.classList.remove('hidden'); root.classList.remove('hidden');
document.body.classList.add('pe-open'); document.body.classList.add('pe-open');
// Load existing sprite (if any), then size + draw.
loadExisting(false, () => { loadExisting(false, () => {
resizeView(); resizeView();
fitToScreen(); fitToScreen();
@@ -362,7 +255,8 @@
} }
function confirmDiscard() { function confirmDiscard() {
if (historyIndex > 0 || floating) { // Only warn if there is edit history beyond the baseline.
if (historyIndex > 0) {
return window.confirm('Discard unsaved changes and close the editor?'); return window.confirm('Discard unsaved changes and close the editor?');
} }
return true; return true;
@@ -376,6 +270,7 @@
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H);
// Draw centered/stretched into the 500x333 buffer.
srcCtx.imageSmoothingEnabled = false; srcCtx.imageSmoothingEnabled = false;
srcCtx.drawImage(img, 0, 0, SPRITE_W, SPRITE_H); srcCtx.drawImage(img, 0, 0, SPRITE_W, SPRITE_H);
if (recordHistory) { pushHistory(); toast('Loaded current sprite'); } if (recordHistory) { pushHistory(); toast('Loaded current sprite'); }
@@ -383,6 +278,7 @@
if (done) done(); if (done) done();
}; };
img.onerror = () => { img.onerror = () => {
// No existing sprite — start from a blank transparent canvas.
if (recordHistory) { pushHistory(); toast('No existing sprite — blank canvas', true); } if (recordHistory) { pushHistory(); toast('No existing sprite — blank canvas', true); }
render(); render();
if (done) done(); if (done) done();
@@ -391,7 +287,6 @@
} }
function clearCanvas() { function clearCanvas() {
floating = null; selection = null; updateSelReadout();
srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H);
pushHistory(); pushHistory();
render(); render();
@@ -401,10 +296,6 @@
// Tool / color selection // Tool / color selection
// =================================================================== // ===================================================================
function setTool(t) { function setTool(t) {
if (t !== tool) {
stampFloating(); // commit any lifted pixels before switching
if (t !== 'select') { selection = null; updateSelReadout(); }
}
tool = t; tool = t;
root.querySelectorAll('.pe-tool').forEach(b => { root.querySelectorAll('.pe-tool').forEach(b => {
b.classList.toggle('active', b.dataset.tool === t); b.classList.toggle('active', b.dataset.tool === t);
@@ -420,69 +311,13 @@
} }
} }
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() { function updateCursor() {
let cur = 'crosshair'; let cur = 'crosshair';
if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab'; if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab';
else if (tool === 'picker') cur = 'cell'; else if (tool === 'picker') cur = 'cell';
else if (tool === 'select') cur = 'crosshair';
viewCanvas.style.cursor = cur; 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 // Coordinate mapping
// =================================================================== // ===================================================================
@@ -501,9 +336,6 @@
return { cx: viewCanvas.width / 2, cy: viewCanvas.height / 2 }; 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 // Pointer handlers
// =================================================================== // ===================================================================
@@ -520,26 +352,14 @@
return; return;
} }
if (tool === 'picker') { pickColor(p.x, p.y); return; } if (tool === 'picker') {
pickColor(p.x, p.y);
if (tool === 'select') { onSelectDown(p); return; } return;
}
// All remaining tools draw onto the canvas — commit any floating first.
stampFloating();
if (tool === 'fill') { if (tool === 'fill') {
floodFill(p.x, p.y); floodFill(p.x, p.y);
pushHistory(); 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(); render();
return; return;
} }
@@ -552,30 +372,13 @@
render(); 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) { function onPointerMove(e) {
const p = eventToSource(e); const p = eventToSource(e);
els.pos.textContent = (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H) if (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H) {
? `${p.x}, ${p.y}` : '--, --'; els.pos.textContent = `${p.x}, ${p.y}`;
} else {
els.pos.textContent = '--, --';
}
if (panning && panStart) { if (panning && panStart) {
panX = panStart.panX + (e.clientX - panStart.cx); panX = panStart.panX + (e.clientX - panStart.cx);
@@ -584,31 +387,8 @@
return; 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')) { 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); if (lastDraw) lineApply(lastDraw.x, lastDraw.y, p.x, p.y);
else applyBrush(p.x, p.y); else applyBrush(p.x, p.y);
lastDraw = { x: p.x, y: p.y }; lastDraw = { x: p.x, y: p.y };
@@ -617,25 +397,10 @@
} }
function onPointerUp() { 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) { if (drawing) {
drawing = false; drawing = false;
lastDraw = null; lastDraw = null;
pushHistory(); pushHistory(); // one undo step per completed stroke
if (tool !== 'eraser') recordColor(color);
} }
if (panning) { if (panning) {
panning = false; panning = false;
@@ -647,7 +412,8 @@
function onWheel(e) { function onWheel(e) {
e.preventDefault(); e.preventDefault();
const rect = viewCanvas.getBoundingClientRect(); const rect = viewCanvas.getBoundingClientRect();
zoomAt(e.deltaY < 0 ? 1.15 : 0.87, { cx: e.clientX - rect.left, cy: e.clientY - rect.top }); const focus = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };
zoomAt(e.deltaY < 0 ? 1.15 : 0.87, focus);
} }
// =================================================================== // ===================================================================
@@ -659,14 +425,12 @@
if (tool === 'eraser') { if (tool === 'eraser') {
srcCtx.clearRect(x0, y0, brushSize, brushSize); srcCtx.clearRect(x0, y0, brushSize, brushSize);
} else { } else {
srcCtx.globalAlpha = brushOpacity;
srcCtx.fillStyle = color; srcCtx.fillStyle = color;
srcCtx.fillRect(x0, y0, brushSize, brushSize); srcCtx.fillRect(x0, y0, brushSize, brushSize);
srcCtx.globalAlpha = 1;
} }
} }
// Bresenham line, stamping the brush along it. // Bresenham line between two source pixels, stamping the brush along it.
function lineApply(x0, y0, x1, y1) { function lineApply(x0, y0, x1, y1) {
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
@@ -680,66 +444,6 @@
} }
} }
// ----- 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) { function pickColor(x, y) {
if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return; if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return;
const d = srcCtx.getImageData(x, y, 1, 1).data; const d = srcCtx.getImageData(x, y, 1, 1).data;
@@ -758,6 +462,7 @@
const fill = hexToRgb(color); const fill = hexToRgb(color);
const fillA = 255; const fillA = 255;
// No-op if the target already equals the fill color.
if (target[0] === fill.r && target[1] === fill.g && if (target[0] === fill.r && target[1] === fill.g &&
target[2] === fill.b && target[3] === fillA) return; target[2] === fill.b && target[3] === fillA) return;
@@ -777,104 +482,13 @@
srcCtx.putImageData(img, 0, 0); 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 // Zoom / pan
// =================================================================== // ===================================================================
function zoomAt(factor, focus) { function zoomAt(factor, focus) {
const newZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM); const newZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM);
if (newZoom === zoom) return; if (newZoom === zoom) return;
// Keep the source pixel under the focus point stationary.
const sxBefore = (focus.cx - panX) / zoom; const sxBefore = (focus.cx - panX) / zoom;
const syBefore = (focus.cy - panY) / zoom; const syBefore = (focus.cy - panY) / zoom;
zoom = newZoom; zoom = newZoom;
@@ -907,27 +521,13 @@
const dw = SPRITE_W * zoom, dh = SPRITE_H * zoom; const dw = SPRITE_W * zoom, dh = SPRITE_H * zoom;
// Transparency checkerboard behind the artboard.
drawCheckerboard(panX, panY, dw, dh); drawCheckerboard(panX, panY, dw, dh);
// Reference overlay sits under the sprite so you can trace over it. // The sprite itself, nearest-neighbour scaled.
if (refImage && refVisible) {
viewCtx.globalAlpha = refOpacity;
viewCtx.imageSmoothingEnabled = false;
viewCtx.drawImage(refImage, panX, panY, dw, dh);
viewCtx.globalAlpha = 1;
}
viewCtx.imageSmoothingEnabled = false; viewCtx.imageSmoothingEnabled = false;
viewCtx.drawImage(srcCanvas, panX, panY, dw, dh); 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. // Artboard border.
viewCtx.strokeStyle = '#5c94fc'; viewCtx.strokeStyle = '#5c94fc';
viewCtx.lineWidth = 1; viewCtx.lineWidth = 1;
@@ -935,23 +535,12 @@
if (showGrid && zoom >= GRID_MIN_ZOOM) drawGrid(dw, dh); 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) + '%'; 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) { function drawCheckerboard(ox, oy, dw, dh) {
const tile = 8; const tile = 8;
// Clip to artboard so the checker stays inside the sprite bounds.
viewCtx.save(); viewCtx.save();
viewCtx.beginPath(); viewCtx.beginPath();
viewCtx.rect(ox, oy, dw, dh); viewCtx.rect(ox, oy, dw, dh);
@@ -989,39 +578,35 @@
// Undo / redo // Undo / redo
// =================================================================== // ===================================================================
function pushHistory() { function pushHistory() {
// Drop any redo tail.
if (historyIndex < history.length - 1) { if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1); history = history.slice(0, historyIndex + 1);
} }
history.push(srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)); history.push(srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H));
// Cap memory use.
if (history.length > HISTORY_LIMIT) history.shift(); if (history.length > HISTORY_LIMIT) history.shift();
historyIndex = history.length - 1; historyIndex = history.length - 1;
updateHistoryButtons(); updateHistoryButtons();
} }
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() { function undo() {
stampFloating();
if (historyIndex <= 0) return; if (historyIndex <= 0) return;
historyIndex--; historyIndex--;
restore(historyIndex); srcCtx.putImageData(history[historyIndex], 0, 0);
render();
updateHistoryButtons();
} }
function redo() { function redo() {
if (historyIndex >= history.length - 1) return; if (historyIndex >= history.length - 1) return;
historyIndex++; historyIndex++;
restore(historyIndex); srcCtx.putImageData(history[historyIndex], 0, 0);
render();
updateHistoryButtons();
} }
function updateHistoryButtons() { function updateHistoryButtons() {
els.undo.disabled = historyIndex <= 0 && !floating; els.undo.disabled = historyIndex <= 0;
els.redo.disabled = historyIndex >= history.length - 1; els.redo.disabled = historyIndex >= history.length - 1;
} }
@@ -1030,7 +615,6 @@
// =================================================================== // ===================================================================
function save() { function save() {
if (!spriteType) return; if (!spriteType) return;
stampFloating(); // bake any floating pixels before exporting
const saveBtn = root.querySelector('#pe-save'); const saveBtn = root.querySelector('#pe-save');
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.textContent = 'SAVING…'; saveBtn.textContent = 'SAVING…';
@@ -1043,9 +627,12 @@
const fd = new FormData(); const fd = new FormData();
fd.append('file', blob, `${spriteType}.png`); fd.append('file', blob, `${spriteType}.png`);
try { 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) { if (res.ok) {
toast(`${spriteType} sprite saved`); toast(`${spriteType} sprite saved`);
// Treat the saved state as the new baseline.
history = [srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)]; history = [srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)];
historyIndex = 0; historyIndex = 0;
updateHistoryButtons(); updateHistoryButtons();
@@ -1066,47 +653,28 @@
// =================================================================== // ===================================================================
function onKeyDown(e) { function onKeyDown(e) {
if (!isOpen()) return; if (!isOpen()) return;
// Don't hijack typing in text/number inputs. // Don't hijack typing in inputs.
if (e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && e.target.type !== 'range') { if (e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && e.target.type !== 'range') {
if (e.key === 'Escape') e.target.blur(); if (e.key === 'Escape') e.target.blur();
return; return;
} }
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
if (ctrl) { if (ctrl && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
const k = e.key.toLowerCase(); if (ctrl && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) {
if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } e.preventDefault(); redo(); 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) { switch (e.key) {
case 'Escape': case 'Escape': if (confirmDiscard()) close(); break;
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 'b': case 'B': setTool('pencil'); break;
case 'e': case 'E': setTool('eraser'); break; case 'e': case 'E': setTool('eraser'); break;
case 'i': case 'I': setTool('picker'); break; case 'i': case 'I': setTool('picker'); break;
case 'f': case 'F': setTool('fill'); 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 '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(1.25, centerPoint()); break;
case '-': case '_': zoomAt(0.8, centerPoint()); break; case '-': case '_': zoomAt(0.8, centerPoint()); break;
case '0': fitToScreen(); 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 ' ': case ' ':
if (!spaceDown) { spaceDown = true; updateCursor(); } if (!spaceDown) { spaceDown = true; updateCursor(); }
e.preventDefault(); e.preventDefault();
@@ -1114,8 +682,6 @@
} }
} }
function selOrFloat() { return tool === 'select' && (selection || floating); }
function onKeyUp(e) { function onKeyUp(e) {
if (!isOpen()) return; if (!isOpen()) return;
if (e.key === ' ') { spaceDown = false; updateCursor(); } if (e.key === ' ') { spaceDown = false; updateCursor(); }
@@ -1137,9 +703,10 @@
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); 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) { function toast(msg, isError) {
const el = document.getElementById('toast'); const el = document.getElementById('toast');
if (!el) return; if (!el) { return; }
el.textContent = msg; el.textContent = msg;
el.classList.remove('hidden', 'error'); el.classList.remove('hidden', 'error');
if (isError) el.classList.add('error'); if (isError) el.classList.add('error');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB