DEV Community

Cover image for A more realistic HTML canvas paint tool
Matt Kane
Matt Kane

Posted on • Updated on

A more realistic HTML canvas paint tool

Creating a basic canvas drawing tool is a simple job in JavaScript, but the result is more MS Paint than Monet. However with a few changes you can make a tool that gives much more realistic result. Read on to learn how to build a canvas paint brush, bristle by bristle.

Let's start with the most basic implementation. First you need to set up a simple canvas element in the page.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, user-scalable=no" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>Drawing tools</title>
        <style>
            body {
                margin: 0;
            }
            canvas {
                border: 2px solid black;
            }
        </style>
        <script src="src/index.js" defer></script>
    </head>
    <body>
        <canvas id="canvas" height="600" width="800"></canvas>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The basic procedure is to watch for mousedown or touchstart events, at which point you start drawing. Then on touchmove or mousemove you draw a line from the previous brush location to the current location. You add several listeners to handle ending the drawing.

Here's the basic drawing handler for mouse events:

// Brush colour and size
const colour = "#3d34a5";
const strokeWidth = 25;

// Drawing state
let latestPoint;
let drawing = false;

// Set up our drawing context
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");

// Drawing functions

const continueStroke = newPoint => {
    context.beginPath();
    context.moveTo(latestPoint[0], latestPoint[1]);
    context.strokeStyle = colour;
    context.lineWidth = strokeWidth;
    context.lineCap = "round";
    context.lineJoin = "round";
    context.lineTo(newPoint[0], newPoint[1]);
    context.stroke();

    latestPoint = newPoint;
};

// Event helpers

const startStroke = point => {
    drawing = true;
    latestPoint = point;
};

const BUTTON = 0b01;
const mouseButtonIsDown = buttons => (BUTTON & buttons) === BUTTON;

// Event handlers

const mouseMove = evt => {
    if (!drawing) {
        return;
    }
    continueStroke([evt.offsetX, evt.offsetY]);
};

const mouseDown = evt => {
    if (drawing) {
        return;
    }
    evt.preventDefault();
    canvas.addEventListener("mousemove", mouseMove, false);
    startStroke([evt.offsetX, evt.offsetY]);
};

const mouseEnter = evt => {
    if (!mouseButtonIsDown(evt.buttons) || drawing) {
        return;
    }
    mouseDown(evt);
};

const endStroke = evt => {
    if (!drawing) {
        return;
    }
    drawing = false;
    evt.currentTarget.removeEventListener("mousemove", mouseMove, false);
};

// Register event handlers

canvas.addEventListener("mousedown", mouseDown, false);
canvas.addEventListener("mouseup", endStroke, false);
canvas.addEventListener("mouseout", endStroke, false);
canvas.addEventListener("mouseenter", mouseEnter, false);
Enter fullscreen mode Exit fullscreen mode

We need to add some extra handlers to deal with touch events.

const getTouchPoint = evt => {
    if (!evt.currentTarget) {
        return [0, 0];
    }
    const rect = evt.currentTarget.getBoundingClientRect();
    const touch = evt.targetTouches[0];
    return [touch.clientX - rect.left, touch.clientY - rect.top];
};

const touchStart = evt => {
    if (drawing) {
        return;
    }
    evt.preventDefault();
    startStroke(getTouchPoint(evt));
};

const touchMove = evt => {
    if (!drawing) {
        return;
    }
    continueStroke(getTouchPoint(evt));
};

const touchEnd = evt => {
    drawing = false;
};

canvas.addEventListener("touchstart", touchStart, false);
canvas.addEventListener("touchend", touchEnd, false);
canvas.addEventListener("touchcancel", touchEnd, false);
canvas.addEventListener("touchmove", touchMove, false);
Enter fullscreen mode Exit fullscreen mode

This is the working example.

You can change strokeWidth and colour, but it doesn't look much like a paintbrush. Let's start to fix that.

The first problem with this is that it uses a single line. A real paintbrush is made up of many bristles. Let's see if we can improve our brush by adding bristles.

First we'll change our stroke function to one that draws a single bristle, then when we draw a brush stroke, we'll draw several bristles at once.

const strokeBristle = (origin, destination, width) => {
    context.beginPath();
    context.moveTo(origin[0], origin[1]);
    context.strokeStyle = colour;
    context.lineWidth = width;
    context.lineCap = "round";
    context.lineJoin = "round";
    context.lineTo(destination[0], destination[1]);
    context.stroke();
};

const continueStroke = newPoint => {
    const bristleCount = Math.round(strokeWidth / 3);
    const gap = strokeWidth / bristleCount;
    for (let i = 0; i < bristleCount; i++) {
        strokeBristle(
            [latestPoint[0] + i * gap, latestPoint[1]],
            [newPoint[0] + i * gap, newPoint[1]],
            2
        );
    }
    latestPoint = newPoint;
};
Enter fullscreen mode Exit fullscreen mode

Here's the result:

Now, this is an improvement, but it looks more like a comb than a paintbrush. Each bristle is exactly the same width and position, which isn't much like a real brush. We can improve that with some randomness. Instead of drawing the bristles at exact intervals from each other we can randomly vary the width and position of each one. We'll do this at the start of the stroke, so that it remains the same for the length of the stroke, but varies the next time.

First we'll create a helper function to generate the brush, which we'll store as an array of "bristle" objects.

const makeBrush = size => {
    const brush = [];
    strokeWidth = size;
    let bristleCount = Math.round(size / 3);
    const gap = strokeWidth / bristleCount;
    for (let i = 0; i < bristleCount; i++) {
        const distance =
            i === 0 ? 0 : gap * i + Math.random() * gap / 2 - gap / 2;
        brush.push({
            distance,
            thickness: Math.random() * 2 + 2
        });
    }
    return brush;
};

let currentBrush = makeBrush();
Enter fullscreen mode Exit fullscreen mode

This uses objects that specify the width and position of each bristle, which we can then use to draw the strokes.

const strokeBristle = (origin, destination, width) => {
    context.beginPath();
    context.moveTo(origin[0], origin[1]);
    context.strokeStyle = colour;
    context.lineWidth = width;
    context.lineCap = "round";
    context.lineJoin = "round";
    context.lineTo(destination[0], destination[1]);
    context.stroke();
};

const drawStroke = (bristles, origin, destination) => {
    bristles.forEach(bristle => {
        context.beginPath();
        const bristleOrigin = origin[0] - strokeWidth / 2 + bristle.distance;

        const bristleDestination =
            destination[0] - strokeWidth / 2 + bristle.distance;
        strokeBristle(
            [bristleOrigin, origin[1]],
            [bristleDestination, destination[1]],
            bristle.thickness
        );
    });
};

const continueStroke = newPoint => {
    drawStroke(currentBrush, latestPoint, newPoint);
    latestPoint = newPoint;
};

const startStroke = point => {
    currentBrush = makeBrush(strokeWidth);
    drawing = true;
    latestPoint = point;
};
Enter fullscreen mode Exit fullscreen mode

Here's the result:

This is staring to look a lot better. The bristles are already looking more natural. However it still looks more uniform than a real brush. The problem is the colours are too flat. A real stroke will have colours that vary slightly according to the thickness of the paint, and the angle of the light. We can emulate this by varying the colour slightly in the same way we varied the thickness and position. For this we're going to use a library called TinyColor. The package name is tinycolor2, so npm install it and include it in your file, or if you're not transpiling you can include it from a CDN.

First create a helper to randomly vary the brightness of a colour.

import tinycolor from "tinycolor2";

const varyBrightness = 5;

const varyColour = sourceColour => {
    const amount = Math.round(Math.random() * 2 * varyBrightness);
    const c = tinycolor(sourceColour);
    const varied =
        amount > varyBrightness
            ? c.brighten(amount - varyBrightness)
            : c.darken(amount);
    return varied.toHexString();
};
Enter fullscreen mode Exit fullscreen mode

Now we can extend the makeBrush method to add a colour property.

const makeBrush = size => {
    const brush = [];
    let bristleCount = Math.round(size / 3);
    const gap = strokeWidth / bristleCount;
    for (let i = 0; i < bristleCount; i++) {
        const distance =
            i === 0 ? 0 : gap * i + Math.random() * gap / 2 - gap / 2;
        brush.push({
            distance,
            thickness: Math.random() * 2 + 2,
            colour: varyColour(colour)
        });
    }
    return brush;
};
Enter fullscreen mode Exit fullscreen mode

...and then modify the drawing functions to use the bristle colour:

const strokeBristle = (origin, destination, bristle) => {
    context.beginPath();
    context.moveTo(origin[0], origin[1]);
    context.strokeStyle = bristle.colour;
    context.lineWidth = bristle.thickness;
    context.lineCap = "round";
    context.lineJoin = "round";
    context.lineTo(destination[0], destination[1]);
    context.stroke();
};

const drawStroke = (bristles, origin, destination) => {
    bristles.forEach(bristle => {
        context.beginPath();
        const bristleOrigin = origin[0] - strokeWidth / 2 + bristle.distance;

        const bristleDestination =
            destination[0] - strokeWidth / 2 + bristle.distance;
        strokeBristle(
            [bristleOrigin, origin[1]],
            [bristleDestination, destination[1]],
            bristle
        );
    });
};
Enter fullscreen mode Exit fullscreen mode

Here's the result:

I'm happy with the look of those strokes now, but the problem now is the action. The brush here has a fixed angle which is more like a marker pen. A real brush changes angle as you move. To do this we can make the angle match the direction in which we're moving. This requires some maths.

In our move handler, we know the previous point and the new point. From this we can work out the bearing, which gives us the new angle for the brush. We then draw a line for each bristle from its old posotion and angle to its new position and angle.

First we'll add some helpers that do the trigonometry to work out these angles.

const rotatePoint = (distance, angle, origin) => [
    origin[0] + distance * Math.cos(angle),
    origin[1] + distance * Math.sin(angle)
];

const getBearing = (origin, destination) =>
    (Math.atan2(destination[1] - origin[1], destination[0] - origin[0]) -
        Math.PI / 2) %
    (Math.PI * 2);

const getNewAngle = (origin, destination, oldAngle) => {
    const bearing = getBearing(origin, destination);
    return oldAngle - angleDiff(oldAngle, bearing);
};

const angleDiff = (angleA, angleB) => {
    const twoPi = Math.PI * 2;
    const diff =
        (angleA - (angleB > 0 ? angleB : angleB + twoPi) + Math.PI) % twoPi -
        Math.PI;
    return diff < -Math.PI ? diff + twoPi : diff;
};
Enter fullscreen mode Exit fullscreen mode

We can then update our drawing functions to use the angles.

let currentAngle = 0;

const drawStroke = (bristles, origin, destination, oldAngle, newAngle) => {
    bristles.forEach(bristle => {
        context.beginPath();
        const bristleOrigin = rotatePoint(
            bristle.distance - strokeWidth / 2,
            oldAngle,
            origin
        );

        const bristleDestination = rotatePoint(
            bristle.distance - strokeWidth / 2,
            newAngle,
            destination
        );
        strokeBristle(bristleOrigin, bristleDestination, bristle);
    });
};

const continueStroke = newPoint => {
    const newAngle = getNewAngle(latestPoint, newPoint, currentAngle);
    drawStroke(currentBrush, latestPoint, newPoint, currentAngle, newAngle);
    currentAngle = newAngle % (Math.PI * 2);
    latestPoint = newPoint;
};
Enter fullscreen mode Exit fullscreen mode

This gives the following:

This is a more natural action than before, but the turns are a little strange. This is because it's making sharp changes in angle. We can improve this using bézier curves.

First, update drawStroke to calculate a control point for the curve. We're using the position of the origin point, rotated to the new angle.

const drawStroke = (bristles, origin, destination, oldAngle, newAngle) => {
    bristles.forEach(bristle => {
        context.beginPath();
        const start = bristle.distance - strokeWidth / 2;

        const bristleOrigin = rotatePoint(start, oldAngle, origin);
        const bristleDestination = rotatePoint(start, newAngle, destination);

        const controlPoint = rotatePoint(start, newAngle, origin);

        strokeBristle(bristleOrigin, bristleDestination, bristle, controlPoint);
    });
};
Enter fullscreen mode Exit fullscreen mode

We then update strokeBristle to use a curve instead of the straight line:

const strokeBristle = (origin, destination, bristle, controlPoint) => {
    context.beginPath();
    context.moveTo(origin[0], origin[1]);
    context.strokeStyle = bristle.colour;
    context.lineWidth = bristle.thickness;
    context.lineCap = "round";
    context.lineJoin = "round";
    context.shadowColor = bristle.colour;
    context.shadowBlur = bristle.thickness / 2;
    context.quadraticCurveTo(
        controlPoint[0],
        controlPoint[1],
        destination[0],
        destination[1]
    );
    context.stroke();
};
Enter fullscreen mode Exit fullscreen mode

This works great, except when we first start a stroke it tries to curve from whatever the previous angle of the brush was, which gives some unnatural results. Our final changes will be to not use the curve when starting a stroke.

let currentAngle;

const getNewAngle = (origin, destination, oldAngle) => {
    const bearing = getBearing(origin, destination);
    if (typeof oldAngle === "undefined") {
        return bearing;
    }
    return oldAngle - angleDiff(oldAngle, bearing);
};

// ...

const startStroke = point => {
    currentAngle = undefined;
    currentBrush = makeBrush(strokeWidth);
    drawing = true;
    latestPoint = point;
};
Enter fullscreen mode Exit fullscreen mode

Here's the final version:

Now, as much as I like purple, you might want to use some other colours. This is a simple addition, with the rarely-used <input type="color">:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, user-scalable=no" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Drawing tools</title>
    <style>
      body {
        margin: 0;
      }
      canvas {
        border: 2px solid black;
      }

      #colourInput {
        position: absolute;
        top: 10px;
        left: 10px;
      }
    </style>
    <script src="src/index.js" defer></script>
  </head>
  <body>
      <canvas id="canvas" height="450" width="800"></canvas>
      <input type="color" id="colourInput" value="#3d34a5" />
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

You then read this value when you start each stroke:

const startStroke = point => {
    colour = document.getElementById("colourInput").value;
    currentAngle = undefined;
    currentBrush = makeBrush(strokeWidth);
    drawing = true;
    latestPoint = point;
};
Enter fullscreen mode Exit fullscreen mode

You could do similar with brush size. You could also try something like brush presets, which change the bristle size and count.

This is the final version with colour picker included:

Try the full-screen version. If you have some suggestions, open a PR on the GitHub repo

Top comments (2)

Collapse
 
ruilour66157976 profile image
Rui Lourenço

Hey ! Is it possible to change the stroke opacity?

Collapse
 
adam_cyclones profile image
Adam Crockett 🌀

Impressive!