1(function (root, factory) {
2 const paletteApi = typeof require === 'function' && typeof module === 'object' && module.exports
3 ? require('./palette.js')
4 : root.PixelPalette;
5 const api = factory(paletteApi);
6 if (typeof module === 'object' && module.exports) module.exports = api;
7 root.PixelEditor = api;
8})(typeof globalThis !== 'undefined' ? globalThis : this, function (paletteApi) {
9 const SIZE = 32;
10 const EMPTY = null;
11 const { DEFAULT_PALETTE, normalizeHex } = paletteApi;
12
13 function createPixels(size = SIZE, fill = EMPTY) {
14 return Array.from({ length: size }, () => Array.from({ length: size }, () => fill));
15 }
16
17 function clonePixels(pixels) {
18 return pixels.map((row) => row.slice());
19 }
20
21 function inBounds(x, y, size = SIZE) {
22 return x >= 0 && y >= 0 && x < size && y < size;
23 }
24
25 function floodFill(pixels, x, y, replacement, size = pixels.length) {
26 if (!inBounds(x, y, size)) return { pixels: clonePixels(pixels), changed: false, count: 0 };
27 replacement = replacement === undefined ? EMPTY : replacement;
28 const target = pixels[y][x];
29 if (target === replacement) return { pixels: clonePixels(pixels), changed: false, count: 0 };
30
31 const next = clonePixels(pixels);
32 const stack = [[x, y]];
33 let count = 0;
34 while (stack.length) {
35 const [cx, cy] = stack.pop();
36 if (!inBounds(cx, cy, size) || next[cy][cx] !== target) continue;
37 next[cy][cx] = replacement;
38 count++;
39 stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
40 }
41 return { pixels: next, changed: count > 0, count };
42 }
43
44 class UndoStack {
45 constructor(initialState, limit = 100) {
46 this.limit = limit;
47 this.stack = [clonePixels(initialState)];
48 this.index = 0;
49 }
50 current() { return clonePixels(this.stack[this.index]); }
51 push(state) {
52 const snapshot = clonePixels(state);
53 this.stack = this.stack.slice(0, this.index + 1);
54 this.stack.push(snapshot);
55 if (this.stack.length > this.limit) this.stack.shift();
56 this.index = this.stack.length - 1;
57 }
58 canUndo() { return this.index > 0; }
59 canRedo() { return this.index < this.stack.length - 1; }
60 undo() { if (this.canUndo()) this.index--; return this.current(); }
61 redo() { if (this.canRedo()) this.index++; return this.current(); }
62 }
63
64 function boot() {
65 if (typeof document === 'undefined') return;
66 const canvas = document.getElementById('canvas');
67 if (!canvas) return;
68 const ctx = canvas.getContext('2d');
69 const preview = document.getElementById('preview');
70 const pctx = preview.getContext('2d');
71 const paletteEl = document.getElementById('palette');
72 const colorPicker = document.getElementById('colorPicker');
73 const toolButtons = [...document.querySelectorAll('[data-tool]')];
74 const zoomInput = document.getElementById('zoom');
75 const zoomLabel = document.getElementById('zoomLabel');
76 const gridToggle = document.getElementById('gridToggle');
77 const status = document.getElementById('status');
78 const jsonFile = document.getElementById('jsonFile');
79
80 const state = {
81 pixels: createPixels(),
82 color: '#ff004d',
83 tool: 'pencil',
84 zoom: Number(zoomInput.value),
85 showGrid: true,
86 drawing: false,
87 lastKey: ''
88 };
89 let history = new UndoStack(state.pixels);
90
91 function setStatus(text) { status.textContent = text; }
92 function cellSize() { return canvas.width / SIZE; }
93 function resize() {
94 const css = SIZE * state.zoom;
95 canvas.style.width = css + 'px';
96 canvas.style.height = css + 'px';
97 zoomLabel.textContent = state.zoom + '×';
98 draw();
99 }
100 function draw() {
101 ctx.imageSmoothingEnabled = false;
102 ctx.clearRect(0, 0, canvas.width, canvas.height);
103 ctx.fillStyle = '#ffffff';
104 ctx.fillRect(0, 0, canvas.width, canvas.height);
105 const c = cellSize();
106 for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) {
107 if (state.pixels[y][x]) {
108 ctx.fillStyle = state.pixels[y][x];
109 ctx.fillRect(x * c, y * c, c, c);
110 }
111 }
112 if (state.showGrid) {
113 ctx.strokeStyle = 'rgba(35,42,58,.55)';
114 ctx.lineWidth = 1;
115 for (let i = 0; i <= SIZE; i++) {
116 ctx.beginPath(); ctx.moveTo(i * c + .5, 0); ctx.lineTo(i * c + .5, canvas.height); ctx.stroke();
117 ctx.beginPath(); ctx.moveTo(0, i * c + .5); ctx.lineTo(canvas.width, i * c + .5); ctx.stroke();
118 }
119 }
120 pctx.imageSmoothingEnabled = false;
121 pctx.clearRect(0, 0, preview.width, preview.height);
122 pctx.drawImage(canvas, 0, 0, preview.width, preview.height);
123 }
124 function commit(before) {
125 if (JSON.stringify(before) !== JSON.stringify(state.pixels)) history.push(state.pixels);
126 draw();
127 }
128 function setTool(tool) {
129 state.tool = tool;
130 toolButtons.forEach((b) => b.classList.toggle('active', b.dataset.tool === tool));
131 setStatus(tool + ' ready');
132 }
133 function setColor(color) {
134 state.color = normalizeHex(color);
135 colorPicker.value = state.color;
136 [...paletteEl.children].forEach((b) => b.classList.toggle('selected', b.dataset.color === state.color));
137 }
138 function pointerCell(e) {
139 const r = canvas.getBoundingClientRect();
140 return {
141 x: Math.floor((e.clientX - r.left) / r.width * SIZE),
142 y: Math.floor((e.clientY - r.top) / r.height * SIZE)
143 };
144 }
145 function applyAt(x, y, withCommit) {
146 if (!inBounds(x, y)) return;
147 const before = withCommit ? clonePixels(state.pixels) : null;
148 if (state.tool === 'pencil') state.pixels[y][x] = state.color;
149 else if (state.tool === 'eraser') state.pixels[y][x] = EMPTY;
150 else if (state.tool === 'eyedropper') { if (state.pixels[y][x]) setColor(state.pixels[y][x]); setTool('pencil'); }
151 else if (state.tool === 'fill') {
152 const result = floodFill(state.pixels, x, y, state.color);
153 state.pixels = result.pixels;
154 }
155 if (withCommit) commit(before); else draw();
156 }
157 function download(name, type, data) {
158 const a = document.createElement('a');
159 a.href = URL.createObjectURL(new Blob([data], { type }));
160 a.download = name;
161 a.click();
162 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
163 }
164 function exportPng() {
165 const out = document.createElement('canvas');
166 out.width = out.height = SIZE * 10;
167 const o = out.getContext('2d');
168 o.imageSmoothingEnabled = false;
169 o.fillStyle = '#ffffff'; o.fillRect(0, 0, out.width, out.height);
170 for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) if (state.pixels[y][x]) {
171 o.fillStyle = state.pixels[y][x]; o.fillRect(x * 10, y * 10, 10, 10);
172 }
173 const a = document.createElement('a'); a.download = 'pixel-art-320.png'; a.href = out.toDataURL('image/png'); a.click();
174 }
175 function exportJson() { download('pixel-art.json', 'application/json', JSON.stringify({ size: SIZE, pixels: state.pixels }, null, 2)); }
176 function importJson(text) {
177 const data = JSON.parse(text);
178 if (data.size !== SIZE || !Array.isArray(data.pixels) || data.pixels.length !== SIZE) throw new Error('Expected 32x32 JSON');
179 state.pixels = data.pixels.map((row) => row.map((c) => c ? normalizeHex(c) : EMPTY));
180 history = new UndoStack(state.pixels);
181 draw();
182 }
183
184 DEFAULT_PALETTE.forEach((color) => {
185 const b = document.createElement('button'); b.className = 'swatch'; b.dataset.color = color; b.title = color; b.style.background = color;
186 b.addEventListener('click', () => setColor(color)); paletteEl.appendChild(b);
187 });
188 toolButtons.forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool)));
189 colorPicker.addEventListener('input', (e) => setColor(e.target.value));
190 zoomInput.addEventListener('input', (e) => { state.zoom = Number(e.target.value); resize(); });
191 gridToggle.addEventListener('change', (e) => { state.showGrid = e.target.checked; draw(); });
192 document.getElementById('undoBtn').onclick = () => { state.pixels = history.undo(); draw(); };
193 document.getElementById('redoBtn').onclick = () => { state.pixels = history.redo(); draw(); };
194 document.getElementById('clearBtn').onclick = () => { const before = clonePixels(state.pixels); state.pixels = createPixels(); commit(before); };
195 document.getElementById('pngBtn').onclick = exportPng;
196 document.getElementById('jsonBtn').onclick = exportJson;
197 document.getElementById('importBtn').onclick = () => jsonFile.click();
198 jsonFile.onchange = () => jsonFile.files[0]?.text().then(importJson).catch((e) => alert(e.message));
199 canvas.addEventListener('pointerdown', (e) => { state.drawing = true; canvas.setPointerCapture(e.pointerId); const p = pointerCell(e); applyAt(p.x, p.y, true); });
200 canvas.addEventListener('pointermove', (e) => { if (!state.drawing || !['pencil', 'eraser'].includes(state.tool)) return; const p = pointerCell(e); applyAt(p.x, p.y, false); });
201 canvas.addEventListener('pointerup', () => { state.drawing = false; history.push(state.pixels); });
202 window.addEventListener('keydown', (e) => {
203 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); state.pixels = e.shiftKey ? history.redo() : history.undo(); draw(); }
204 else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); state.pixels = history.redo(); draw(); }
205 else if (e.key === 'p') setTool('pencil'); else if (e.key === 'e') setTool('eraser'); else if (e.key === 'f') setTool('fill');
206 else if (e.key === 'i') setTool('eyedropper'); else if (e.key === 'g') { gridToggle.checked = state.showGrid = !state.showGrid; draw(); }
207 else if (e.key === '+' || e.key === '=') { zoomInput.value = state.zoom = Math.min(24, state.zoom + 1); resize(); }
208 else if (e.key === '-') { zoomInput.value = state.zoom = Math.max(8, state.zoom - 1); resize(); }
209 });
210
211 setColor(state.color); setTool('pencil'); resize();
212 }
213
214 if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', boot);
215 return { SIZE, EMPTY, createPixels, clonePixels, floodFill, UndoStack, normalizeHex };
216});
217
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.