Original notes for JavaScript360.org — project concept inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke, built here with an original architecture, tool set, and code. See the full attribution note.
Chapter 19 · Project: A Pixel Art Editor
Estimated time: ~35 minutes · Chapter type: Guided project
This project doesn't introduce much new syntax — instead, it's about architecture: combining state management, canvas drawing, and event handling from the last several chapters into one coherent small application, the way you'd structure a real feature.
The core idea: state → render, one more time
Just like the game and counter widget in earlier chapters, this editor follows the same loop: state changes → re-render. The state here is simply a grid of colors:
function createBlankPicture(width, height, fillColor = '#ffffff') {
return {
width,
height,
pixels: new Array(width * height).fill(fillColor),
};
}
function pixelAt(picture, x, y) {
return picture.pixels[y * picture.width + x];
}
function setPixel(picture, x, y, color) {
// Immutable update, same principle as the delivery robot's TownState in Chapter 7:
// return a new picture rather than mutating the one we were given.
const pixels = picture.pixels.slice();
pixels[y * picture.width + x] = color;
return { ...picture, pixels };
}
Storing the grid as a flat array (y * width + x indexing) instead of an array of arrays keeps the update logic simple and is a common pattern for any 2D grid in JavaScript.
Rendering the picture to canvas
function renderPicture(ctx, picture, pixelSize) {
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
ctx.fillStyle = pixelAt(picture, x, y);
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
Because each pixel of our picture becomes a whole filled square on the real canvas (pixelSize might be 16 or 20 real pixels per art "pixel"), the art stays chunky and editable no matter how small the underlying grid is.
Turning mouse clicks into grid coordinates
The trickiest new piece is translating a raw mouse click (real screen pixels) into a coordinate in our picture's grid:
function eventToGridPosition(event, canvas, pixelSize) {
const rect = canvas.getBoundingClientRect(); // Chapter 14: layout position on screen
const x = Math.floor((event.clientX - rect.left) / pixelSize);
const y = Math.floor((event.clientY - rect.top) / pixelSize);
return { x, y };
}
Tools: a small, swappable set of behaviors
Rather than hard-coding "clicking always draws," we model each tool as a function with the same signature — (picture, position, color) => newPicture — so switching tools is just switching which function gets called, a lightweight application of the higher-order function ideas from Chapter 5:
const tools = {
draw(picture, { x, y }, color) {
return setPixel(picture, x, y, color);
},
fill(picture, { x, y }, color) {
return floodFill(picture, x, y, pixelAt(picture, x, y), color);
},
pickColor(picture, { x, y }, _color, onPick) {
onPick(pixelAt(picture, x, y)); // "picks" the clicked pixel's color for later use
return picture;
},
};
function floodFill(picture, x, y, targetColor, replacementColor) {
if (targetColor === replacementColor) return picture;
if (x < 0 || x >= picture.width || y < 0 || y >= picture.height) return picture;
if (pixelAt(picture, x, y) !== targetColor) return picture;
picture = setPixel(picture, x, y, replacementColor);
// Recurse to the four neighbors — the same recursive shape as Chapter 3's flatten(),
// just walking a grid instead of a nested array.
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
picture = floodFill(picture, x + dx, y + dy, targetColor, replacementColor);
}
return picture;
}
floodFill is the same recursive idea used to implement a paint bucket in real image editors: fill this pixel, then try to fill each neighbor of the same original color, stopping automatically at the boundary where the color changes.
Wiring it together with events
let state = { picture: createBlankPicture(16, 16), color: '#000000', tool: 'draw' };
function applyTool(position) {
state.picture = tools[state.tool](state.picture, position, state.color, (picked) => {
state.color = picked;
});
renderPicture(ctx, state.picture, pixelSize);
}
canvas.addEventListener('mousedown', (event) => {
applyTool(eventToGridPosition(event, canvas, pixelSize));
});
canvas.addEventListener('mousemove', (event) => {
if (event.buttons !== 1) return; // only draw while the mouse button is actually held down
applyTool(eventToGridPosition(event, canvas, pixelSize));
});
event.buttons (distinct from event.button) is a bitmask telling you which buttons are currently held down — checking it inside mousemove is what lets the user drag to draw a continuous line rather than only placing single pixels.
What this project actually taught you
- Modeling application state as one plain, immutable object, updated by pure functions (
setPixel,floodFill) - Mapping between two coordinate systems — real screen pixels and your own logical grid — using
getBoundingClientRect - Representing interchangeable behaviors (tools) as functions with a shared signature, so adding a new tool means adding one new function, not branching logic everywhere
- Flood fill as a concrete, satisfying application of recursion (Chapter 3) to a 2D grid
Extend it yourself
- Add an undo stack: keep a history array of previous
picturestates, and push onto it before every tool application. - Add a "line" tool that draws a straight line between where the mouse went down and where it went up, using
mousedown/mouseupinstead of continuousmousemove. - Persist the picture to
localStorage(the same mechanism this course uses for progress tracking) so a refresh doesn't lose the artwork.