Skip to main content
Adapted, not copied

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 picture states, 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/mouseup instead of continuous mousemove.
  • Persist the picture to localStorage (the same mechanism this course uses for progress tracking) so a refresh doesn't lose the artwork.
 
Up nextCh. 20Node.js~40 min

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →