Skip to content

Commit cf04d4c

Browse files
committed
Created Pixel Art Editor
1 parent 839d904 commit cf04d4c

File tree

2 files changed

+383
-0
lines changed

2 files changed

+383
-0
lines changed

19_a_pixel_art_editor/index.html

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Pixel Art Editor</title>
8+
</head>
9+
<body>
10+
<div></div>
11+
12+
<script src="./pixel_art_editor.js"></script>
13+
</body>
14+
</html>
+369
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
class Picture {
2+
constructor(width, height, pixels) {
3+
this.width = width;
4+
this.height = height;
5+
this.pixels = pixels;
6+
}
7+
static empty(width, height, color) {
8+
let pixels = new Array(width * height).fill(color);
9+
return new Picture(width, height, pixels);
10+
}
11+
pixel(x, y) {
12+
return this.pixels[x + y * this.width];
13+
}
14+
draw(pixels) {
15+
let copy = this.pixels.slice();
16+
for (let { x, y, color } of pixels) {
17+
copy[x + y * this.width] = color;
18+
}
19+
return new Picture(this.width, this.height, copy);
20+
}
21+
}
22+
23+
function updateState(state, action) {
24+
return Object.assign({}, state, action);
25+
}
26+
27+
function elt(type, props, ...children) {
28+
let dom = document.createElement(type);
29+
if (props) Object.assign(dom, props);
30+
for (let child of children) {
31+
if (typeof child != 'string') dom.appendChild(child);
32+
else dom.appendChild(document.createTextNode(child));
33+
}
34+
return dom;
35+
}
36+
37+
const scale = 10;
38+
class PictureCanvas {
39+
constructor(picture, pointerDown) {
40+
this.dom = elt('canvas', {
41+
onmousedown: (event) => this.mouse(event, pointerDown),
42+
ontouchstart: (event) => this.touch(event, pointerDown),
43+
});
44+
this.syncState(picture);
45+
}
46+
syncState(picture) {
47+
if (this.picture == picture) return;
48+
this.picture = picture;
49+
drawPicture(this.picture, this.dom, scale);
50+
}
51+
}
52+
53+
function drawPicture(picture, canvas, scale) {
54+
canvas.width = picture.width * scale;
55+
canvas.height = picture.height * scale;
56+
let cx = canvas.getContext('2d');
57+
for (let y = 0; y < picture.height; y++) {
58+
for (let x = 0; x < picture.width; x++) {
59+
cx.fillStyle = picture.pixel(x, y);
60+
cx.fillRect(x * scale, y * scale, scale, scale);
61+
}
62+
}
63+
}
64+
65+
PictureCanvas.prototype.mouse = function (downEvent, onDown) {
66+
if (downEvent.button != 0) return;
67+
let pos = pointerPosition(downEvent, this.dom);
68+
let onMove = onDown(pos);
69+
if (!onMove) return;
70+
let move = (moveEvent) => {
71+
if (moveEvent.buttons == 0) {
72+
this.dom.removeEventListener('mousemove', move);
73+
} else {
74+
let newPos = pointerPosition(moveEvent, this.dom);
75+
if (newPos.x == pos.x && newPos.y == pos.y) return;
76+
pos = newPos;
77+
onMove(newPos);
78+
}
79+
};
80+
this.dom.addEventListener('mousemove', move);
81+
};
82+
function pointerPosition(pos, domNode) {
83+
let rect = domNode.getBoundingClientRect();
84+
return {
85+
x: Math.floor((pos.clientX - rect.left) / scale),
86+
y: Math.floor((pos.clientY - rect.top) / scale),
87+
};
88+
}
89+
90+
PictureCanvas.prototype.touch = function (startEvent, onDown) {
91+
let pos = pointerPosition(startEvent.touches[0], this.dom);
92+
let onMove = onDown(pos);
93+
startEvent.preventDefault();
94+
if (!onMove) return;
95+
let move = (moveEvent) => {
96+
let newPos = pointerPosition(moveEvent.touches[0], this.dom);
97+
if (newPos.x == pos.x && newPos.y == pos.y) return;
98+
pos = newPos;
99+
onMove(newPos);
100+
};
101+
let end = () => {
102+
this.dom.removeEventListener('touchmove', move);
103+
this.dom.removeEventListener('touchend', end);
104+
};
105+
this.dom.addEventListener('touchmove', move);
106+
this.dom.addEventListener('touchend', end);
107+
};
108+
109+
class PixelEditor {
110+
constructor(state, config) {
111+
let { tools, controls, dispatch } = config;
112+
this.state = state;
113+
this.canvas = new PictureCanvas(state.picture, (pos) => {
114+
let tool = tools[this.state.tool];
115+
let onMove = tool(pos, this.state, dispatch);
116+
if (onMove) return (pos) => onMove(pos, this.state);
117+
});
118+
this.controls = controls.map((Control) => new Control(state, config));
119+
this.dom = elt(
120+
'div',
121+
{},
122+
this.canvas.dom,
123+
elt('br'),
124+
...this.controls.reduce((a, c) => a.concat(' ', c.dom), [])
125+
);
126+
}
127+
syncState(state) {
128+
this.state = state;
129+
this.canvas.syncState(state.picture);
130+
for (let ctrl of this.controls) ctrl.syncState(state);
131+
}
132+
}
133+
134+
class ToolSelect {
135+
constructor(state, { tools, dispatch }) {
136+
this.select = elt(
137+
'select',
138+
{
139+
onchange: () => dispatch({ tool: this.select.value }),
140+
},
141+
...Object.keys(tools).map((name) =>
142+
elt(
143+
'option',
144+
{
145+
selected: name == state.tool,
146+
},
147+
name
148+
)
149+
)
150+
);
151+
this.dom = elt('label', null, ' Tool: ', this.select);
152+
}
153+
syncState(state) {
154+
this.select.value = state.tool;
155+
}
156+
}
157+
158+
class ColorSelect {
159+
constructor(state, { dispatch }) {
160+
this.input = elt('input', {
161+
type: 'color',
162+
value: state.color,
163+
onchange: () => dispatch({ color: this.input.value }),
164+
});
165+
this.dom = elt('label', null, ' Color: ', this.input);
166+
}
167+
syncState(state) {
168+
this.input.value = state.color;
169+
}
170+
}
171+
172+
function draw(pos, state, dispatch) {
173+
function drawPixel({ x, y }, state) {
174+
let drawn = { x, y, color: state.color };
175+
dispatch({ picture: state.picture.draw([drawn]) });
176+
}
177+
drawPixel(pos, state);
178+
return drawPixel;
179+
}
180+
181+
function rectangle(start, state, dispatch) {
182+
function drawRectangle(pos) {
183+
let xStart = Math.min(start.x, pos.x);
184+
let yStart = Math.min(start.y, pos.y);
185+
let xEnd = Math.max(start.x, pos.x);
186+
let yEnd = Math.max(start.y, pos.y);
187+
let drawn = [];
188+
for (let y = yStart; y <= yEnd; y++) {
189+
for (let x = xStart; x <= xEnd; x++) {
190+
drawn.push({ x, y, color: state.color });
191+
}
192+
}
193+
dispatch({ picture: state.picture.draw(drawn) });
194+
}
195+
drawRectangle(start);
196+
return drawRectangle;
197+
}
198+
199+
const around = [
200+
{ dx: -1, dy: 0 },
201+
{ dx: 1, dy: 0 },
202+
{ dx: 0, dy: -1 },
203+
{ dx: 0, dy: 1 },
204+
];
205+
function fill({ x, y }, state, dispatch) {
206+
let targetColor = state.picture.pixel(x, y);
207+
let drawn = [{ x, y, color: state.color }];
208+
for (let done = 0; done < drawn.length; done++) {
209+
for (let { dx, dy } of around) {
210+
let x = drawn[done].x + dx,
211+
y = drawn[done].y + dy;
212+
if (
213+
x >= 0 &&
214+
x < state.picture.width &&
215+
y >= 0 &&
216+
y < state.picture.height &&
217+
state.picture.pixel(x, y) == targetColor &&
218+
!drawn.some((p) => p.x == x && p.y == y)
219+
) {
220+
drawn.push({ x, y, color: state.color });
221+
}
222+
}
223+
}
224+
dispatch({ picture: state.picture.draw(drawn) });
225+
}
226+
227+
function pick(pos, state, dispatch) {
228+
dispatch({ color: state.picture.pixel(pos.x, pos.y) });
229+
}
230+
231+
class SaveButton {
232+
constructor(state) {
233+
this.picture = state.picture;
234+
this.dom = elt(
235+
'button',
236+
{
237+
onclick: () => this.save(),
238+
},
239+
'💾 Save'
240+
);
241+
}
242+
save() {
243+
let canvas = elt('canvas');
244+
drawPicture(this.picture, canvas, 1);
245+
let link = elt('a', {
246+
href: canvas.toDataURL(),
247+
download: 'pixelart.png',
248+
});
249+
document.body.appendChild(link);
250+
link.click();
251+
link.remove();
252+
}
253+
syncState(state) {
254+
this.picture = state.picture;
255+
}
256+
}
257+
258+
class LoadButton {
259+
constructor(_, { dispatch }) {
260+
this.dom = elt(
261+
'button',
262+
{
263+
onclick: () => startLoad(dispatch),
264+
},
265+
'📁 Load'
266+
);
267+
}
268+
syncState() {}
269+
}
270+
271+
function startLoad(dispatch) {
272+
let input = elt('input', {
273+
type: 'file',
274+
onchange: () => finishLoad(input.files[0], dispatch),
275+
});
276+
document.body.appendChild(input);
277+
input.click();
278+
input.remove();
279+
}
280+
281+
function finishLoad(file, dispatch) {
282+
if (file == null) return;
283+
let reader = new FileReader();
284+
reader.addEventListener('load', () => {
285+
let image = elt('img', {
286+
onload: () =>
287+
dispatch({
288+
picture: pictureFromImage(image),
289+
}),
290+
src: reader.result,
291+
});
292+
});
293+
reader.readAsDataURL(file);
294+
}
295+
296+
function pictureFromImage(image) {
297+
let width = Math.min(100, image.width);
298+
let height = Math.min(100, image.height);
299+
let canvas = elt('canvas', { width, height });
300+
let cx = canvas.getContext('2d');
301+
cx.drawImage(image, 0, 0);
302+
let pixels = [];
303+
let { data } = cx.getImageData(0, 0, width, height);
304+
function hex(n) {
305+
return n.toString(16).padStart(2, '0');
306+
}
307+
for (let i = 0; i < data.length; i += 4) {
308+
let [r, g, b] = data.slice(i, i + 3);
309+
pixels.push('#' + hex(r) + hex(g) + hex(b));
310+
}
311+
return new Picture(width, height, pixels);
312+
}
313+
314+
function historyUpdateState(state, action) {
315+
if (action.undo == true) {
316+
if (state.done.length == 0) return state;
317+
return Object.assign({}, state, {
318+
picture: state.done[0],
319+
done: state.done.slice(1),
320+
doneAt: 0,
321+
});
322+
} else if (action.picture && state.doneAt < Date.now() - 1000) {
323+
return Object.assign({}, state, action, {
324+
done: [state.picture, ...state.done],
325+
doneAt: Date.now(),
326+
});
327+
} else {
328+
return Object.assign({}, state, action);
329+
}
330+
}
331+
332+
class UndoButton {
333+
constructor(state, { dispatch }) {
334+
this.dom = elt(
335+
'button',
336+
{
337+
onclick: () => dispatch({ undo: true }),
338+
disabled: state.done.length == 0,
339+
},
340+
'🔙 Undo'
341+
);
342+
}
343+
syncState(state) {
344+
this.dom.disabled = state.done.length == 0;
345+
}
346+
}
347+
348+
const startState = {
349+
tool: 'draw',
350+
color: '#000000',
351+
picture: Picture.empty(60, 30, '#f0f0f0'),
352+
done: [],
353+
doneAt: 0,
354+
};
355+
const baseTools = { draw, fill, rectangle, pick };
356+
const baseControls = [ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton];
357+
function startPixelEditor({ state = startState, tools = baseTools, controls = baseControls }) {
358+
let app = new PixelEditor(state, {
359+
tools,
360+
controls,
361+
dispatch(action) {
362+
state = historyUpdateState(state, action);
363+
app.syncState(state);
364+
},
365+
});
366+
return app.dom;
367+
}
368+
369+
document.querySelector('div').appendChild(startPixelEditor({}));

0 commit comments

Comments
 (0)