1
2
3
4
5(function (global) {
6 'use strict';
7
8 const GRID_SIZE = 32;
9
10
11
12 function createGrid(size = GRID_SIZE, fill = null) {
13 return Array.from({ length: size }, () => new Array(size).fill(fill));
14 }
15
16 function cloneGrid(pixels) {
17 return pixels.map((row) => row.slice());
18 }
19
20 function gridsEqual(a, b) {
21 if (a.length !== b.length) return false;
22 for (let y = 0; y < a.length; y++) {
23 if (a[y].length !== b[y].length) return false;
24 for (let x = 0; x < a[y].length; x++) {
25 if (a[y][x] !== b[y][x]) return false;
26 }
27 }
28 return true;
29 }
30
31
32 function floodFill(pixels, x, y, color) {
33 const h = pixels.length;
34 const w = h ? pixels[0].length : 0;
35 if (x < 0 || y < 0 || x >= w || y >= h) return 0;
36 const target = pixels[y][x];
37 if (target === color) return 0;
38 let changed = 0;
39 const stack = [[x, y]];
40 while (stack.length) {
41 const [cx, cy] = stack.pop();
42 if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue;
43 if (pixels[cy][cx] !== target) continue;
44 pixels[cy][cx] = color;
45 changed += 1;
46 stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
47 }
48 return changed;
49 }
50
51
52
53 class UndoStack {
54 constructor(limit = 100) {
55 this.limit = limit;
56 this.past = [];
57 this.future = [];
58 }
59
60 push(snapshot) {
61 this.past.push(snapshot);
62 if (this.past.length > this.limit) this.past.shift();
63 this.future.length = 0;
64 }
65
66 undo(current) {
67 if (!this.past.length) return null;
68 this.future.push(current);
69 return this.past.pop();
70 }
71
72 redo(current) {
73 if (!this.future.length) return null;
74 this.past.push(current);
75 return this.future.pop();
76 }
77
78 canUndo() {
79 return this.past.length > 0;
80 }
81
82 canRedo() {
83 return this.future.length > 0;
84 }
85 }
86
87 function serialize(pixels) {
88 return JSON.stringify({
89 app: 'pixel-art-editor',
90 version: 1,
91 width: pixels[0].length,
92 height: pixels.length,
93 pixels,
94 });
95 }
96
97 function deserialize(json) {
98 const data = JSON.parse(json);
99 if (!data || !Array.isArray(data.pixels)) throw new Error('missing "pixels" array');
100 const rows = data.pixels;
101 if (rows.length !== GRID_SIZE) throw new Error(`expected ${GRID_SIZE} rows, got ${rows.length}`);
102 const grid = createGrid();
103 for (let y = 0; y < GRID_SIZE; y++) {
104 if (!Array.isArray(rows[y]) || rows[y].length !== GRID_SIZE) {
105 throw new Error(`row ${y} is not ${GRID_SIZE} cells`);
106 }
107 for (let x = 0; x < GRID_SIZE; x++) {
108 const v = rows[y][x];
109 if (v === null) continue;
110 if (typeof v !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(v)) {
111 throw new Error(`bad pixel at ${x},${y}: ${JSON.stringify(v)}`);
112 }
113 grid[y][x] = v.toLowerCase();
114 }
115 }
116 return grid;
117 }
118
119 const core = {
120 GRID_SIZE,
121 createGrid,
122 cloneGrid,
123 gridsEqual,
124 floodFill,
125 UndoStack,
126 serialize,
127 deserialize,
128 };
129
130 if (typeof module !== 'undefined' && module.exports) module.exports = core;
131 global.PixelEditorCore = core;
132
133
134
135 if (typeof document === 'undefined') return;
136
137 function initEditor() {
138 const canvas = document.getElementById('canvas');
139 if (!canvas) return;
140 const ctx = canvas.getContext('2d');
141
142 const paletteApi = global.PixelPalette || { COLORS: ['#000000', '#ffffff'], DEFAULT_COLOR: '#ffffff' };
143 const ZOOM_LEVELS = [4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32];
144 const EXPORT_SCALE = 10;
145
146 const state = {
147 pixels: createGrid(),
148 color: paletteApi.DEFAULT_COLOR,
149 tool: 'pencil',
150 zoom: 14,
151 showGrid: true,
152 history: new UndoStack(200),
153 stroke: null,
154 strokeChanged: false,
155 };
156
157 const toolButtons = Array.from(document.querySelectorAll('[data-tool]'));
158 const undoBtn = document.getElementById('undo');
159 const redoBtn = document.getElementById('redo');
160 const zoomLabel = document.getElementById('zoom-label');
161 const gridBtn = document.getElementById('grid-toggle');
162 const paletteEl = document.getElementById('palette');
163 const currentSwatch = document.getElementById('current-swatch');
164 const currentHex = document.getElementById('current-hex');
165 const customColor = document.getElementById('custom-color');
166 const importInput = document.getElementById('import-file');
167 const statusEl = document.getElementById('status');
168
169
170
171 function render() {
172 const size = GRID_SIZE * state.zoom;
173 if (canvas.width !== size) {
174 canvas.width = size;
175 canvas.height = size;
176 }
177 const z = state.zoom;
178 for (let y = 0; y < GRID_SIZE; y++) {
179 for (let x = 0; x < GRID_SIZE; x++) {
180 ctx.fillStyle = state.pixels[y][x] || ((x + y) % 2 ? '#26262d' : '#1f1f26');
181 ctx.fillRect(x * z, y * z, z, z);
182 }
183 }
184 if (state.showGrid && z >= 6) {
185 drawGridLines(size, z, 1, 'rgba(255, 255, 255, 0.07)');
186 drawGridLines(size, z, 8, 'rgba(255, 255, 255, 0.16)');
187 }
188 updateControls();
189 }
190
191 function drawGridLines(size, z, step, style) {
192 ctx.strokeStyle = style;
193 ctx.lineWidth = 1;
194 ctx.beginPath();
195 for (let i = step; i < GRID_SIZE; i += step) {
196 ctx.moveTo(i * z + 0.5, 0);
197 ctx.lineTo(i * z + 0.5, size);
198 ctx.moveTo(0, i * z + 0.5);
199 ctx.lineTo(size, i * z + 0.5);
200 }
201 ctx.stroke();
202 }
203
204 function updateControls() {
205 undoBtn.disabled = !state.history.canUndo();
206 redoBtn.disabled = !state.history.canRedo();
207 zoomLabel.textContent = `${state.zoom}×`;
208 gridBtn.setAttribute('aria-pressed', String(state.showGrid));
209 gridBtn.classList.toggle('active', state.showGrid);
210 toolButtons.forEach((b) => b.classList.toggle('active', b.dataset.tool === state.tool));
211 }
212
213 function updateStatus(cell) {
214 statusEl.textContent = cell ? `${state.tool} — (${cell.x}, ${cell.y})` : state.tool;
215 }
216
217
218
219 function setTool(tool) {
220 state.tool = tool;
221 updateControls();
222 updateStatus(null);
223 }
224
225 function setColor(color) {
226 state.color = color.toLowerCase();
227 currentSwatch.style.background = state.color;
228 currentHex.textContent = state.color;
229 if (/^#[0-9a-f]{6}$/.test(state.color)) customColor.value = state.color;
230 paletteEl.querySelectorAll('.swatch').forEach((b) => {
231 b.classList.toggle('active', b.dataset.color === state.color);
232 });
233 }
234
235 function cellFromEvent(e) {
236 const rect = canvas.getBoundingClientRect();
237 const x = Math.floor(((e.clientX - rect.left) * (canvas.width / rect.width)) / state.zoom);
238 const y = Math.floor(((e.clientY - rect.top) * (canvas.height / rect.height)) / state.zoom);
239 if (x < 0 || y < 0 || x >= GRID_SIZE || y >= GRID_SIZE) return null;
240 return { x, y };
241 }
242
243 function paint(cell) {
244 const color = state.tool === 'eraser' ? null : state.color;
245 if (state.pixels[cell.y][cell.x] !== color) {
246 state.pixels[cell.y][cell.x] = color;
247 state.strokeChanged = true;
248 }
249 }
250
251 canvas.addEventListener('pointerdown', (e) => {
252 e.preventDefault();
253 const cell = cellFromEvent(e);
254 if (!cell) return;
255 if (state.tool === 'eyedropper') {
256 const picked = state.pixels[cell.y][cell.x];
257 if (picked) setColor(picked);
258 return;
259 }
260 if (state.tool === 'fill') {
261 const before = cloneGrid(state.pixels);
262 if (floodFill(state.pixels, cell.x, cell.y, state.color) > 0) {
263 state.history.push(before);
264 }
265 render();
266 return;
267 }
268 state.stroke = cloneGrid(state.pixels);
269 state.strokeChanged = false;
270 canvas.setPointerCapture(e.pointerId);
271 paint(cell);
272 render();
273 });
274
275 canvas.addEventListener('pointermove', (e) => {
276 const cell = cellFromEvent(e);
277 updateStatus(cell);
278 if (!state.stroke || !cell) return;
279 paint(cell);
280 render();
281 });
282
283 function endStroke() {
284 if (!state.stroke) return;
285 if (state.strokeChanged) state.history.push(state.stroke);
286 state.stroke = null;
287 updateControls();
288 }
289
290 canvas.addEventListener('pointerup', endStroke);
291 canvas.addEventListener('pointercancel', endStroke);
292 canvas.addEventListener('pointerleave', () => updateStatus(null));
293
294
295
296 function doUndo() {
297 const prev = state.history.undo(cloneGrid(state.pixels));
298 if (prev) {
299 state.pixels = prev;
300 render();
301 }
302 }
303
304 function doRedo() {
305 const next = state.history.redo(cloneGrid(state.pixels));
306 if (next) {
307 state.pixels = next;
308 render();
309 }
310 }
311
312 function zoomBy(step) {
313 const i = ZOOM_LEVELS.indexOf(state.zoom);
314 const next = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, Math.max(0, i + step))];
315 if (next !== state.zoom) {
316 state.zoom = next;
317 render();
318 }
319 }
320
321 function toggleGrid() {
322 state.showGrid = !state.showGrid;
323 render();
324 }
325
326
327
328 function download(name, href, revoke) {
329 const a = document.createElement('a');
330 a.href = href;
331 a.download = name;
332 document.body.appendChild(a);
333 a.click();
334 a.remove();
335 if (revoke) setTimeout(() => URL.revokeObjectURL(href), 1000);
336 }
337
338 function exportPNG() {
339 const off = document.createElement('canvas');
340 off.width = GRID_SIZE * EXPORT_SCALE;
341 off.height = GRID_SIZE * EXPORT_SCALE;
342 const octx = off.getContext('2d');
343 for (let y = 0; y < GRID_SIZE; y++) {
344 for (let x = 0; x < GRID_SIZE; x++) {
345 const v = state.pixels[y][x];
346 if (!v) continue;
347 octx.fillStyle = v;
348 octx.fillRect(x * EXPORT_SCALE, y * EXPORT_SCALE, EXPORT_SCALE, EXPORT_SCALE);
349 }
350 }
351 download('pixel-art.png', off.toDataURL('image/png'));
352 }
353
354 function exportJSON() {
355 const blob = new Blob([serialize(state.pixels)], { type: 'application/json' });
356 download('pixel-art.json', URL.createObjectURL(blob), true);
357 }
358
359 importInput.addEventListener('change', () => {
360 const file = importInput.files[0];
361 if (!file) return;
362 const reader = new FileReader();
363 reader.onload = () => {
364 try {
365 const grid = deserialize(reader.result);
366 state.history.push(cloneGrid(state.pixels));
367 state.pixels = grid;
368 render();
369 } catch (err) {
370 alert(`Import failed: ${err.message}`);
371 }
372 importInput.value = '';
373 };
374 reader.readAsText(file);
375 });
376
377
378
379 toolButtons.forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool)));
380 undoBtn.addEventListener('click', doUndo);
381 redoBtn.addEventListener('click', doRedo);
382 document.getElementById('zoom-in').addEventListener('click', () => zoomBy(1));
383 document.getElementById('zoom-out').addEventListener('click', () => zoomBy(-1));
384 gridBtn.addEventListener('click', toggleGrid);
385 document.getElementById('export-png').addEventListener('click', exportPNG);
386 document.getElementById('export-json').addEventListener('click', exportJSON);
387 document.getElementById('import-json').addEventListener('click', () => importInput.click());
388 customColor.addEventListener('input', () => setColor(customColor.value));
389
390 paletteApi.COLORS.forEach((color) => {
391 const b = document.createElement('button');
392 b.className = 'swatch';
393 b.style.background = color;
394 b.title = color;
395 b.dataset.color = color.toLowerCase();
396 b.addEventListener('click', () => setColor(color));
397 paletteEl.appendChild(b);
398 });
399
400 window.addEventListener('keydown', (e) => {
401 const t = e.target;
402 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
403 const mod = e.ctrlKey || e.metaKey;
404 const key = e.key.toLowerCase();
405 if (mod && key === 'z' && !e.shiftKey) {
406 e.preventDefault();
407 doUndo();
408 return;
409 }
410 if (mod && (key === 'y' || (key === 'z' && e.shiftKey))) {
411 e.preventDefault();
412 doRedo();
413 return;
414 }
415 if (mod) return;
416 switch (key) {
417 case 'p': setTool('pencil'); break;
418 case 'e': setTool('eraser'); break;
419 case 'f': setTool('fill'); break;
420 case 'i': setTool('eyedropper'); break;
421 case 'g': toggleGrid(); break;
422 case '+': case '=': zoomBy(1); break;
423 case '-': case '_': zoomBy(-1); break;
424 }
425 });
426
427 setColor(state.color);
428 setTool('pencil');
429 render();
430 }
431
432 if (document.readyState === 'loading') {
433 document.addEventListener('DOMContentLoaded', initEditor);
434 } else {
435 initEditor();
436 }
437})(typeof globalThis !== 'undefined' ? globalThis : this);
438
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.