Flood Fill and Line Tool for HTML Canvas

Tom Cantwell
6 min readNov 26, 2020

Flood Fill is a common tool for drawing apps, but HTML Canvas doesn’t have a built-in flood fill function. And although there is a built-in function for drawing a straight line with lineTo, it’s a bit lacking for customization, so we’re going to go over how to make both a flood fill function as well as a line tool. I’ve built these functions to go along with an undo button infrastructure, so these functions can be used for more advanced drawing apps.

Click the color swatch to change colors

The context for these tools is that I’m using an offscreen canvas as the true drawing surface and rendering it to an onscreen canvas as we draw. This is helpful to keep pixel sizes consistent while allowing for smooth scaling of the canvas and temporary graphics such as cursors, zooming, and a line preview for the line tool.

For every mouse event, we use this code to first get the mouse coordinates as it relates to the offscreen canvas, since we’re clicking on the onscreen canvas:

let trueRatio = onScreenCVS.offsetWidth / offScreenCVS.width;
let mouseX = Math.floor(e.offsetX / trueRatio);
let mouseY = Math.floor(e.offsetY / trueRatio);

Flood Fill

Before we look at the function itself, let’s go over when it gets activated and how it relates to the undo stack. For the standard draw function, I’m storing all the points that we draw as the mouse position changes on the mousemove event and pushing that whole array as one action to the undostack on the mouseup event.

For a flood fill, we only need to store one point, the origin point, where the user clicked. So for flood fill, we only need to use the mousedown event to run the function, and the mouseup event to push the action to the undo stack.

If the tool type is “fill”, run this in the mousedown event:

actionFill(mouseX, mouseY, brushColor);
points.push({
x: mouseX,
y: mouseY,
color: { ...brushColor },
mode: toolType
});
//Render onto onscreen canvas
source = offScreenCVS.toDataURL();
renderImage();

Push to undo stack on mouseup:

//add to undo stack
undoStack.push(points);
points = [];
//Reset redostack
redoStack = [];

Now the actual fill function. As you can see, we’re passing in three arguments, the coordinates plus the color. It’s important to pass in color rather than just use the current brush color, because for the undo/redo function we need to be able to run this function again with whatever color was used for past actions.

We’ll be manipulating the color data itself rather than filling pixels, so first we need to get the imagedata, along with the index of the pixel we clicked on and its color.

function actionFill(startX, startY, currentColor) {
//get imageData
let colorLayer = offScreenCTX.getImageData(
0,
0,
offScreenCVS.width,
offScreenCVS.height
);
let startPos = (startY * offScreenCVS.width + startX) * 4; //clicked color
let startR = colorLayer.data[startPos];
let startG = colorLayer.data[startPos + 1];
let startB = colorLayer.data[startPos + 2];

If the color clicked is the same as the current brush color we passed in, exit here.

  //exit if color is the same
if (
currentColor.r === startR &&
currentColor.g === startG &&
currentColor.b === startB
) {
return;
}

The next bit of code I adopted from William Malone’s code for a floodfill function:

  //Start with click coords
let pixelStack = [[startX, startY]];
let newPos, x, y, pixelPos, reachLeft, reachRight;
floodFill();
function floodFill() {
newPos = pixelStack.pop();
x = newPos[0];
y = newPos[1];
//get current pixel position
pixelPos = (y * offScreenCVS.width + x) * 4;
// Go up as long as the color matches and are inside the canvas
while (y >= 0 && matchStartColor(pixelPos)) {
y--;
pixelPos -= offScreenCVS.width * 4;
}
//Don't overextend
pixelPos += offScreenCVS.width * 4;
y++;
reachLeft = false;
reachRight = false;
// Go down as long as the color matches and in inside the canvas
while (y < offScreenCVS.height && matchStartColor(pixelPos)) {
colorPixel(pixelPos);
if (x > 0) {
if (matchStartColor(pixelPos - 4)) {
if (!reachLeft) {
//Add pixel to stack
pixelStack.push([x - 1, y]);
reachLeft = true;
}
} else if (reachLeft) {
reachLeft = false;
}
}
if (x < offScreenCVS.width - 1) {
if (matchStartColor(pixelPos + 4)) {
if (!reachRight) {
//Add pixel to stack
pixelStack.push([x + 1, y]);
reachRight = true;
}
} else if (reachRight) {
reachRight = false;
}
}
y++;
pixelPos += offScreenCVS.width * 4;
}
//recursive until no more pixels to change
if (pixelStack.length) {
floodFill();
}
}
//render floodFill result
offScreenCTX.putImageData(colorLayer, 0, 0);
//helpers
function matchStartColor(pixelPos) {
let r = colorLayer.data[pixelPos];
let g = colorLayer.data[pixelPos + 1];
let b = colorLayer.data[pixelPos + 2];
return r === startR && g === startG && b === startB;
}
function colorPixel(pixelPos) {
colorLayer.data[pixelPos] = currentColor.r;
colorLayer.data[pixelPos + 1] = currentColor.g;
colorLayer.data[pixelPos + 2] = currentColor.b;
colorLayer.data[pixelPos + 3] = 255;
}
}

Line Tool

This tool is a bit different in that you need to generate a preview before actually rendering the line, so the artist can see what their line will look like before actually drawing it. On the mousedown event, all we need is to set the starting point of the line:

//Set origin point
lineX = mouseX;
lineY = mouseY;

On mouseup, we can use the origin point and the current point to draw a line, and we’ll push both points to the undostack:

  //draw line if line tool
if (toolType === "line") {
//render line
actionLine(lineX, lineY, mouseX, mouseY, brushColor, offScreenCTX);
points.push({
startX: lineX,
startY: lineY,
endX: mouseX,
endY: mouseY,
color: { ...brushColor },
mode: toolType
});
source = offScreenCVS.toDataURL();
renderImage();
}

As you can see, this time when we call the action, we’re also passing in the context. This is because when the mouse moves before letting the mouse up, we need to generate a preview which we’ll draw only on the onscreen canvas. In the mousemove event, we need to gather the onscreen coordinates. ocwidth is the onscreen canvas width:

let ratio = ocWidth / offScreenCVS.width;
let onX = mouseX * ratio;
let onY = mouseY * ratio;

All that’s really doing is creating appropriately rounded coordinates according to the scale difference between the onscreen and offscreen canvas. We’ll also keep track of the last onscreen coordinates, and only render the preview line when the coordinates change.

  //only draw when necessary
if (onX !== lastOnX || onY !== lastOnY) {
onScreenCTX.clearRect(0, 0, ocWidth, ocHeight);
drawCanvas();
//set offscreen endpoint
lastX = mouseX;
lastY = mouseY;
actionLine(
lineX,
lineY,
mouseX,
mouseY,
brushColor,
onScreenCTX,
ratio
);
lastOnX = onX;
lastOnY = onY;
}

Not only are we passing in the onscreen context now, but we have an additional argument, which is the scale difference between contexts. Let’s look at the line function:

function actionLine(sx, sy, tx, ty, currentColor, ctx, scale = 1) {
ctx.fillStyle = currentColor.color;
// finds the distance between points
function lineLength(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}
// finds the angle of (x,y) on a plane from the origin
function getAngle(x, y) {
return Math.atan(y / (x == 0 ? 0.01 : x)) + (x < 0 ? Math.PI : 0);
}
let dist = lineLength(sx, sy, tx, ty); // length of line
let ang = getAngle(tx - sx, ty - sy); // angle of line
for (let i = 0; i < dist; i++) {
// for each point along the line
ctx.fillRect(
Math.round(sx + Math.cos(ang) * i) * scale, // round for perfect pixels
Math.round(sy + Math.sin(ang) * i) * scale, // thus no aliasing
scale,
scale
);
}
//fill endpoint
ctx.fillRect(
Math.round(tx) * scale, // round for perfect pixels
Math.round(ty) * scale, // thus no aliasing
scale,
scale
); // fill in one pixel, 1x1
}

The parameter scale has a default value of 1, so when this is called for the offscreen canvas, it draws the pixels at original size. For the onscreen preview, we passed in the arguments for the offscreen endpoints, not the onscreen endpoints. This way, we can just scale it up appropriately and efficiently generate a preview line instead of trying to calculate how to draw a matching pixelated line using a much bigger line length. Finally, we have to draw the last point on the line separately, to make sure the endpoint is directly under the mouse.

--

--