1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17function floodFill(grid, width, height, startX, startY, fillColor) {
18 if (!grid || startX < 0 || startX >= width || startY < 0 || startY >= height) {
19 return grid;
20 }
21
22 const targetIdx = startY * width + startX;
23 const targetColor = grid[targetIdx];
24
25
26 const normalize = (c) => (c ? c.toUpperCase() : null);
27 const normTarget = normalize(targetColor);
28 const normFill = normalize(fillColor);
29
30
31 if (normTarget === normFill) {
32 return grid;
33 }
34
35 const newGrid = [...grid];
36 const queue = [[startX, startY]];
37 const visited = new Uint8Array(width * height);
38
39 visited[targetIdx] = 1;
40
41 while (queue.length > 0) {
42 const [x, y] = queue.shift();
43 const idx = y * width + x;
44 newGrid[idx] = fillColor ? fillColor.toUpperCase() : null;
45
46
47 const neighbors = [
48 [x + 1, y],
49 [x - 1, y],
50 [x, y + 1],
51 [x, y - 1]
52 ];
53
54 for (let i = 0; i < neighbors.length; i++) {
55 const [nx, ny] = neighbors[i];
56 if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
57 const nIdx = ny * width + nx;
58 if (!visited[nIdx]) {
59 visited[nIdx] = 1;
60 if (normalize(newGrid[nIdx]) === normTarget) {
61 queue.push([nx, ny]);
62 }
63 }
64 }
65 }
66 }
67
68 return newGrid;
69}
70
71
72class UndoManager {
73 constructor(maxSize = 50) {
74 this.maxSize = maxSize;
75 this.stack = [];
76 this.currentIndex = -1;
77 }
78
79
80
81
82
83 push(state) {
84 if (!state) return;
85
86 if (this.currentIndex < this.stack.length - 1) {
87 this.stack = this.stack.slice(0, this.currentIndex + 1);
88 }
89
90 this.stack.push([...state]);
91
92
93 if (this.stack.length > this.maxSize) {
94 this.stack.shift();
95 } else {
96 this.currentIndex++;
97 }
98 }
99
100 canUndo() {
101 return this.currentIndex > 0;
102 }
103
104 canRedo() {
105 return this.currentIndex < this.stack.length - 1;
106 }
107
108 undo() {
109 if (!this.canUndo()) return null;
110 this.currentIndex--;
111 return [...this.stack[this.currentIndex]];
112 }
113
114 redo() {
115 if (!this.canRedo()) return null;
116 this.currentIndex++;
117 return [...this.stack[this.currentIndex]];
118 }
119
120 getCurrentState() {
121 if (this.currentIndex >= 0 && this.currentIndex < this.stack.length) {
122 return [...this.stack[this.currentIndex]];
123 }
124 return null;
125 }
126
127 clear() {
128 this.stack = [];
129 this.currentIndex = -1;
130 }
131}
132
133
134function exportToJSON(grid, width = 32, height = 32) {
135 return JSON.stringify({
136 version: '1.0',
137 width,
138 height,
139 createdAt: new Date().toISOString(),
140 pixels: grid
141 }, null, 2);
142}
143
144function importFromJSON(jsonString, expectedWidth = 32, expectedHeight = 32) {
145 let parsed;
146 try {
147 parsed = typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString;
148 } catch (err) {
149 throw new Error('Invalid JSON format');
150 }
151
152 if (!parsed || typeof parsed !== 'object') {
153 throw new Error('JSON data must be an object');
154 }
155
156 if (!Array.isArray(parsed.pixels)) {
157 throw new Error('JSON data must contain a valid "pixels" array');
158 }
159
160 const expectedLength = expectedWidth * expectedHeight;
161 if (parsed.pixels.length !== expectedLength) {
162 throw new Error(`Invalid pixels count. Expected ${expectedLength}, got ${parsed.pixels.length}`);
163 }
164
165 return {
166 width: parsed.width || expectedWidth,
167 height: parsed.height || expectedHeight,
168 pixels: parsed.pixels.map(p => (p ? String(p).toUpperCase() : null))
169 };
170}
171
172
173class PixelEditor {
174 constructor(options = {}) {
175 this.width = options.width || 32;
176 this.height = options.height || 32;
177 this.scale = options.scale || 16;
178 this.showGrid = options.showGrid !== undefined ? options.showGrid : true;
179 this.currentTool = options.tool || 'pencil';
180
181 this.paletteManager = options.paletteManager || (typeof PaletteManager !== 'undefined' ? new PaletteManager() : null);
182
183
184 this.grid = new Array(this.width * this.height).fill(null);
185
186 this.undoManager = new UndoManager(50);
187
188 this.undoManager.push(this.grid);
189
190 this.isMouseDown = false;
191 this.lastMousePos = null;
192 this.isStrokeActive = false;
193
194
195 this.canvas = options.canvas || null;
196 this.ctx = null;
197 }
198
199 attachCanvas(canvasElement) {
200 this.canvas = canvasElement;
201 this.ctx = this.canvas.getContext('2d');
202 this.resizeCanvas();
203 this.setupEventListeners();
204 this.render();
205 }
206
207 resizeCanvas() {
208 if (!this.canvas) return;
209 this.canvas.width = this.width * this.scale;
210 this.canvas.height = this.height * this.scale;
211 }
212
213 setZoom(scale) {
214 this.scale = Math.max(2, Math.min(40, scale));
215 this.resizeCanvas();
216 this.render();
217 }
218
219 toggleGrid() {
220 this.showGrid = !this.showGrid;
221 this.render();
222 return this.showGrid;
223 }
224
225 setTool(tool) {
226 const validTools = ['pencil', 'eraser', 'fill', 'eyedropper'];
227 if (validTools.includes(tool)) {
228 this.currentTool = tool;
229 }
230 }
231
232 getPixel(x, y) {
233 if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null;
234 return this.grid[y * this.width + x];
235 }
236
237 setPixelInternal(x, y, color) {
238 if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
239 this.grid[y * this.width + x] = color ? color.toUpperCase() : null;
240 }
241
242
243 drawLine(x0, y0, x1, y1, color) {
244 const dx = Math.abs(x1 - x0);
245 const dy = Math.abs(y1 - y0);
246 const sx = x0 < x1 ? 1 : -1;
247 const sy = y0 < y1 ? 1 : -1;
248 let err = dx - dy;
249
250 let x = x0;
251 let y = y0;
252
253 while (true) {
254 this.setPixelInternal(x, y, color);
255 if (x === x1 && y === y1) break;
256 const e2 = 2 * err;
257 if (e2 > -dy) {
258 err -= dy;
259 x += sx;
260 }
261 if (e2 < dx) {
262 err += dx;
263 y += sy;
264 }
265 }
266 }
267
268 handlePointerDown(x, y, isRightClick = false) {
269 if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
270
271 this.isMouseDown = true;
272 this.lastMousePos = { x, y };
273
274 const colorToUse = isRightClick
275 ? (this.paletteManager ? this.paletteManager.getSecondaryColor() : '#FFFFFF')
276 : (this.paletteManager ? this.paletteManager.getPrimaryColor() : '#000000');
277
278 if (this.currentTool === 'eyedropper') {
279 const pickedColor = this.getPixel(x, y);
280 if (pickedColor && this.paletteManager) {
281 if (isRightClick) {
282 this.paletteManager.setSecondaryColor(pickedColor);
283 } else {
284 this.paletteManager.setPrimaryColor(pickedColor);
285 }
286 }
287 return;
288 }
289
290 if (this.currentTool === 'fill') {
291 const targetColor = this.getPixel(x, y);
292 const fillC = isRightClick ? (this.paletteManager ? this.paletteManager.getSecondaryColor() : null) : colorToUse;
293
294 this.grid = floodFill(this.grid, this.width, this.height, x, y, fillC);
295 this.undoManager.push(this.grid);
296 this.render();
297 return;
298 }
299
300
301 const color = (this.currentTool === 'eraser') ? null : colorToUse;
302 this.setPixelInternal(x, y, color);
303 this.render();
304 }
305
306 handlePointerMove(x, y, isRightClick = false) {
307 if (!this.isMouseDown) return;
308 if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
309
310 if (this.currentTool === 'pencil' || this.currentTool === 'eraser') {
311 const colorToUse = isRightClick
312 ? (this.paletteManager ? this.paletteManager.getSecondaryColor() : '#FFFFFF')
313 : (this.paletteManager ? this.paletteManager.getPrimaryColor() : '#000000');
314
315 const color = (this.currentTool === 'eraser') ? null : colorToUse;
316
317 if (this.lastMousePos) {
318 this.drawLine(this.lastMousePos.x, this.lastMousePos.y, x, y, color);
319 } else {
320 this.setPixelInternal(x, y, color);
321 }
322
323 this.lastMousePos = { x, y };
324 this.isStrokeActive = true;
325 this.render();
326 }
327 }
328
329 handlePointerUp() {
330 if (this.isMouseDown) {
331 if (this.currentTool === 'pencil' || this.currentTool === 'eraser') {
332
333 this.undoManager.push(this.grid);
334 }
335 }
336 this.isMouseDown = false;
337 this.lastMousePos = null;
338 this.isStrokeActive = false;
339 }
340
341 undo() {
342 const prevState = this.undoManager.undo();
343 if (prevState) {
344 this.grid = prevState;
345 this.render();
346 }
347 return prevState;
348 }
349
350 redo() {
351 const nextState = this.undoManager.redo();
352 if (nextState) {
353 this.grid = nextState;
354 this.render();
355 }
356 return nextState;
357 }
358
359 clearCanvas() {
360 this.grid = new Array(this.width * this.height).fill(null);
361 this.undoManager.push(this.grid);
362 this.render();
363 }
364
365 exportPNG(scaleFactor = 10) {
366 const offCanvas = document.createElement('canvas');
367 offCanvas.width = this.width * scaleFactor;
368 offCanvas.height = this.height * scaleFactor;
369 const offCtx = offCanvas.getContext('2d');
370
371 offCtx.imageSmoothingEnabled = false;
372
373 for (let y = 0; y < this.height; y++) {
374 for (let x = 0; x < this.width; x++) {
375 const color = this.grid[y * this.width + x];
376 if (color) {
377 offCtx.fillStyle = color;
378 offCtx.fillRect(x * scaleFactor, y * scaleFactor, scaleFactor, scaleFactor);
379 }
380 }
381 }
382
383 return offCanvas.toDataURL('image/png');
384 }
385
386 downloadPNG(filename = 'pixel-art.png') {
387 const dataUrl = this.exportPNG(10);
388 const link = document.createElement('a');
389 link.download = filename;
390 link.href = dataUrl;
391 document.body.appendChild(link);
392 link.click();
393 document.body.removeChild(link);
394 }
395
396 exportJSON() {
397 return exportToJSON(this.grid, this.width, this.height);
398 }
399
400 downloadJSON(filename = 'pixel-art.json') {
401 const jsonStr = this.exportJSON();
402 const blob = new Blob([jsonStr], { type: 'application/json' });
403 const url = URL.createObjectURL(blob);
404 const link = document.createElement('a');
405 link.download = filename;
406 link.href = url;
407 document.body.appendChild(link);
408 link.click();
409 document.body.removeChild(link);
410 URL.revokeObjectURL(url);
411 }
412
413 loadJSON(jsonString) {
414 const imported = importFromJSON(jsonString, this.width, this.height);
415 this.grid = imported.pixels;
416 this.undoManager.push(this.grid);
417 this.render();
418 }
419
420 setupEventListeners() {
421 if (!this.canvas) return;
422
423 const getCanvasCoords = (e) => {
424 const rect = this.canvas.getBoundingClientRect();
425 const scaleX = this.canvas.width / rect.width;
426 const scaleY = this.canvas.height / rect.height;
427
428 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
429 const clientY = e.touches ? e.touches[0].clientY : e.clientY;
430
431 const canvasX = (clientX - rect.left) * scaleX;
432 const canvasY = (clientY - rect.top) * scaleY;
433
434 const x = Math.floor(canvasX / this.scale);
435 const y = Math.floor(canvasY / this.scale);
436
437 return { x, y };
438 };
439
440 this.canvas.addEventListener('mousedown', (e) => {
441 e.preventDefault();
442 const { x, y } = getCanvasCoords(e);
443 const isRightClick = e.button === 2;
444 this.handlePointerDown(x, y, isRightClick);
445 });
446
447 this.canvas.addEventListener('mousemove', (e) => {
448 const { x, y } = getCanvasCoords(e);
449 const isRightClick = e.buttons === 2;
450 this.handlePointerMove(x, y, isRightClick);
451
452 if (this.onHoverCallback) {
453 this.onHoverCallback(x, y);
454 }
455 });
456
457 window.addEventListener('mouseup', () => {
458 this.handlePointerUp();
459 });
460
461 this.canvas.addEventListener('mouseleave', () => {
462 if (this.onHoverCallback) {
463 this.onHoverCallback(-1, -1);
464 }
465 });
466
467
468 this.canvas.addEventListener('touchstart', (e) => {
469 e.preventDefault();
470 const { x, y } = getCanvasCoords(e);
471 this.handlePointerDown(x, y, false);
472 });
473
474 this.canvas.addEventListener('touchmove', (e) => {
475 e.preventDefault();
476 const { x, y } = getCanvasCoords(e);
477 this.handlePointerMove(x, y, false);
478 });
479
480 this.canvas.addEventListener('touchend', (e) => {
481 e.preventDefault();
482 this.handlePointerUp();
483 });
484
485
486 this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
487 }
488
489 render() {
490 if (!this.ctx || !this.canvas) return;
491
492
493 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
494
495
496 const checkSize = Math.max(4, Math.floor(this.scale / 2));
497 for (let y = 0; y < this.canvas.height; y += checkSize) {
498 for (let x = 0; x < this.canvas.width; x += checkSize) {
499 const isDark = ((x / checkSize) + (y / checkSize)) % 2 === 0;
500 this.ctx.fillStyle = isDark ? '#1e293b' : '#334155';
501 this.ctx.fillRect(x, y, checkSize, checkSize);
502 }
503 }
504
505
506 for (let y = 0; y < this.height; y++) {
507 for (let x = 0; x < this.width; x++) {
508 const color = this.grid[y * this.width + x];
509 if (color) {
510 this.ctx.fillStyle = color;
511 this.ctx.fillRect(x * this.scale, y * this.scale, this.scale, this.scale);
512 }
513 }
514 }
515
516
517 if (this.showGrid && this.scale >= 4) {
518 this.ctx.lineWidth = 1;
519 this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
520
521 this.ctx.beginPath();
522
523 for (let x = 0; x <= this.width; x++) {
524 this.ctx.moveTo(x * this.scale + 0.5, 0);
525 this.ctx.lineTo(x * this.scale + 0.5, this.height * this.scale);
526 }
527
528 for (let y = 0; y <= this.height; y++) {
529 this.ctx.moveTo(0, y * this.scale + 0.5);
530 this.ctx.lineTo(this.width * this.scale, y * this.scale + 0.5);
531 }
532 this.ctx.stroke();
533 }
534 }
535}
536
537
538if (typeof module !== 'undefined' && module.exports) {
539 module.exports = {
540 floodFill,
541 UndoManager,
542 exportToJSON,
543 importFromJSON,
544 PixelEditor
545 };
546} else if (typeof window !== 'undefined') {
547 window.floodFill = floodFill;
548 window.UndoManager = UndoManager;
549 window.exportToJSON = exportToJSON;
550 window.importFromJSON = importFromJSON;
551 window.PixelEditor = PixelEditor;
552}
553
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.