DEV Community

Cover image for Build your own drawing board
Phuoc Nguyen
Phuoc Nguyen

Posted on • Updated on • Originally published at phuoc.ng

Build your own drawing board

In our previous post, we learned about the useRef() hook and how we can use it to add smooth animation to a slider. Now, we'll show you another real-life example of how to take advantage of the useRef hook. We'll build a drawing board that allows users to draw by clicking and moving the mouse.

This drawing board can be used in many cases, such as creating an e-signature pad. With the rise of remote work and online transactions, e-signatures have become increasingly important. Our drawing board can capture the user's signature as an image, making it easy to add to any document or form. This is especially useful for businesses that need to process contracts or legal documents remotely.

Another real-life example where our drawing board component can be used is in an online whiteboard application. With the growing number of remote work and virtual meetings, having a digital whiteboard that allows users to draw and collaborate can be very useful. Our drawing board component can be integrated into such an application, allowing users to sketch out their ideas and share them with others in real-time. It can also be used as a tool for educational purposes, where teachers or students can use it to illustrate concepts or solve problems collaboratively.

Understanding the data structure

When it comes to building a drawing board component, we have two main options: using canvas or SVG elements.

Canvas is a powerful element that lets us create shapes and images dynamically. It's perfect for complex applications that need real-time updates. With canvas, we can build a drawing board that responds smoothly to user input and offers various drawing tools like pencils, brushes, and erasers.

SVG, on the other hand, is a vector-based image format that uses SVG tags to define graphics. It's great for creating scalable graphics and animations that can be easily manipulated with CSS or JavaScript. With SVG, we can build a lightweight and accessible drawing board that supports advanced features like gradients, filters, and masks.

Each option has its own strengths and weaknesses, so we need to choose the one that fits our application's specific needs. In the following sections, we'll explore how to build a drawing board component using SVG.

To create this drawing board with SVG, we first need a data structure to hold the lines that the user draws. We'll define a Line type that has an id and an array of Points. Each Point has an x and y coordinate that we'll use to draw the line.

type Point = {
    x: number;
    y: number;
};
type Line = {
    id: string;
    points: Point[];
};
Enter fullscreen mode Exit fullscreen mode

The id property is randomly generated to tell lines apart. These lines mimic what users draw on the board.

To create a single line representing what users draw on the board, we use the lines generated as users click and move their mouse. We then map each line to a polyline element in SVG. The points attribute of the polyline element takes a string of x,y coordinates that define the path of the line. We create this string by mapping each point in the line's array of points to a string representation of its coordinates using template literals and joining them with spaces.

By mapping all lines to polylines, we can recreate what users drew on the board.

Here's some sample code to give you an idea of how to recreate the lines. The id properties differentiate between lines and are used as the key attribute.

<svg>
{
    lines.map(({ id, points }) => (
        <polyline
            key={id}
            points={points.map((point) => `${point.x},${point.y}`).join(" ")}
        />
    ))
}
</svg>;
Enter fullscreen mode Exit fullscreen mode

Using this data structure makes it a breeze to store and retrieve lines in a database.

Storing the lines in a database has many benefits. Firstly, it allows us to reload the drawing board with the previous content if needed. Additionally, storing the lines in a data structure makes it easy to modify and manipulate the drawing board's content. We can add new lines or delete existing ones by manipulating the data structure before rendering it on the board.

For instance, we can save an array of Line objects that represent all the lines drawn on a specific drawing board in a database. Then, when a user opens the drawing board again, we can fetch this array from the database and render each line as a polyline on the SVG element.

By doing this, we preserve any previous work done on the drawing board and make it simple for users to pick up where they left off.

Building the drawing board component

Now that you know the data structure we'll use to create our drawing board component, let's get to building it. We'll start by using the useRef() hook to create a reference to the SVG element.

const DrawingBoard = () => {
    const svgRef = React.useRef();

    // Render
    return <svg className="board" ref={svgRef}></svg>;
};
Enter fullscreen mode Exit fullscreen mode

The SVG element comes with the board CSS class. It's nothing special, really, except for the cursor we use, which lets users know they can draw with their mouse.

.board {
    cursor: crosshair;
}
Enter fullscreen mode Exit fullscreen mode

To store the data in the structure described earlier, we use two internal states: id and lines.

The id state represents the ID of the latest line, while the lines state represents all lines. The id state is initially set to an empty string, and the lines state is set to an empty array since no lines have been drawn yet.

const [id, setId] = React.useState("");
const [lines, setLines] = React.useState<Line[]>([]);
Enter fullscreen mode Exit fullscreen mode

Furthermore, we utilize a separate state to monitor mouse movement by users. This state is initially set to false.

const [isDrawing, setIsDrawing] = React.useState(false);
Enter fullscreen mode Exit fullscreen mode

It's finally time to see the good stuff! We need to handle the mousedown event to detect when the user clicks on the drawing board. Here's how we do it:

const handleMouseDown = (e) => {
    const id = generateId();
    const svgRect = svgRef.current.getBoundingClientRect();
    const startingPoint = {
        x: e.clientX - svgRect.x,
        y: e.clientY - svgRect.y,
    };
    setIsDrawing(true);
    setId(id);
    setLines((lines) =>
        lines.concat({
            id,
            points: [startingPoint],
        })
    );
};

// Render
return (
    <svg ref={svgRef} onMouseDown={handleMouseDown}></svg>
);
Enter fullscreen mode Exit fullscreen mode

In the handler, we generate a new ID for the line that users will draw and calculate its starting point by subtracting the SVG element's coordinates from the client's current position. We use the svgRef.current.getBoundingClientRect() method to find the SVG element's position relative to the viewport and create the startingPoint object using these coordinates.

Then, we set isDrawing to true to indicate that users are drawing on the board. We update both the id and lines states with a newly generated ID and an array containing a single point at that moment. This creates a new line with an ID and one point representing where users started drawing.

There are several ways to generate a random ID, but for this example, we'll be using the function mentioned in this post.

As users move the mouse around, we need to store the lines they draw. To do this, we handle the mousemove event.

Here's the code snippet for the mousemove event handler:

const handleMouseMove = (e) => {
    if (!isDrawing) {
        return;
    }
    const svgRect = svgRef.current.getBoundingClientRect();

    setLines((lines) =>
        lines.map((line) =>
            line.id === id
            ? {
                ...line,
                points: line.points.concat({
                    x: e.clientX - svgRect.x,
                    y: e.clientY - svgRect.y,
                }),
            }
            : line
        )
    );
};
Enter fullscreen mode Exit fullscreen mode

The handleMouseMove function tracks the user's mouse movement after they have clicked down on the board to start drawing. First, the function checks whether the user is currently drawing by checking the isDrawing state. If they are not, then the function does nothing.

If the user is currently drawing, we retrieve the position of the SVG element relative to the viewport using svgRef.current.getBoundingClientRect(). This information helps us calculate where the user's mouse cursor currently is and add these new coordinates to our data structure for lines.

We use the setLines method to update our state. We map over all existing lines and check which line is currently being drawn by comparing its id with our current id. If it matches, we append a new point object containing x and y coordinates calculated from subtracting the SVG's top-left corner's coordinates from the clientX and clientY coordinates of MouseEvent e.

This creates a new point at each move event that represents where the user's mouse cursor currently is. When the user finishes their drawing and lifts their mouse up, we can store all points as one line in our data structure for lines.

Finally, when the user releases the mouse to stop drawing, we update the corresponding state by setting the isDrawing state to false.

const handMouseUp = () => {
    setIsDrawing(false);
};
Enter fullscreen mode Exit fullscreen mode

When users move their mouse over the entire SVG element, the same thing should occur.

const handMouseLeave = () => {
    setIsDrawing(false);
};
Enter fullscreen mode Exit fullscreen mode

Let's check out how we declare the event handlers:

<svg
    ref={svgRef}
    onMouseDown={handleMouseDown}
    onMouseMove={handleMouseMove}
    onMouseUp={handMouseUp}
    onMouseLeave={handMouseLeave}
>
  ...
</svg>
Enter fullscreen mode Exit fullscreen mode

Lastly, we render each line as a polyline. We achieve this by mapping over the corresponding points in our state array and rendering them as attributes of each polyline tag in JSX.

<svg>
{
    lines.map(({ id, points }) => (
        <polyline
            key={id}
            points={points.map((point) => `${point.x},${point.y}`).join(" ")}
        />
    ))
}
</svg>
Enter fullscreen mode Exit fullscreen mode

Check out the demo below:

Making the drawing board mobile-friendly

In order for our drawing board to work on mobile devices, we need to add touch event handling alongside mouse event handling. This will allow users to draw on the board using their fingers instead of a mouse.

To handle touch events, we can use the onTouchStart, onTouchMove, and onTouchEnd events. These events are similar to their mouse counterparts, but they provide information about touches instead of clicks.

Let's start by adding an event listener for touch start events.

const handleTouchStart = (e) => {
    e.preventDefault();
    const id = generateId();
    const svgRect = svgRef.current.getBoundingClientRect();
    const startingPoint = {
        x: e.touches[0].clientX - svgRect.x,
        y: e.touches[0].clientY - svgRect.y,
    };
    setIsDrawing(true);
    setId(id);
    setLines((lines) =>
        lines.concat({
            id,
            points: [startingPoint],
        })
    );
};

// Render
return (
    <svg
        ref={svgRef}
        style={{
            touchAction: "none",
        }}
        onTouchStart={handleTouchStart}
    ></svg>
);
Enter fullscreen mode Exit fullscreen mode

In this code snippet, we added an event listener for touch start events that calls the same handleMouseDown function as our mouse down event handler.

To disable the browser's handling of touch events (like scrolling and zooming), we set the touchAction style property to none.

Now, we're ready to add an event listener for touch move events.

const handleTouchMove = (e) => {
    if (!isDrawing) {
        return;
    }
    const svgRect = svgRef.current.getBoundingClientRect();

    setLines((lines) =>
        lines.map((line) =>
            line.id === id
            ? {
                ...line,
                points: line.points.concat({
                    x: e.touches[0].clientX - svgRect.x,
                    y: e.touches[0].clientY - svgRect.y,
                }),
            }
            : line
        )
    );
};

// Render
return (
    <svg
        ref={svgRef}
        onTouchMove={handleTouchMove}
    ></svg>
);
Enter fullscreen mode Exit fullscreen mode

We've added an event listener for touch move events that calls the same handleMouseMove function as our mouse move event handler. We also check whether users are currently drawing by using the isDrawing state. If they are not, then we do nothing.

To wrap it up, we can add an event listener for touch end events.

const handleTouchEnd = () => {
    setIsDrawing(false);
};

// Render
return (
    <svg
        ref={svgRef}
        onTouchStart={handleTouchStart}
        onTouchMove={handleTouchMove}
        onTouchEnd={handleTouchEnd}
    ></svg>
);
Enter fullscreen mode Exit fullscreen mode

In the code snippet, we added an event listener for when a touch ends, which triggers the same setIsDrawing(false) function that our mouse up event handler uses.

By adding these three touch event listeners to our SVG element, we can now handle drawing on both mobile and desktop devices.

Demo

Take a look at the final demo below. You might notice some duplicated code for handling mouse and touch events. However, if you check out the code in the final demo, you'll see that the common parts have been extracted to separate functions.

It's worth noting that this example demonstrates the power of the useRef() hook for building a drawing board. There's still plenty of room for improvement, such as adding zoom in/out functionality or undo/redo functionality. These tasks are left for you to tackle.

See also


It's highly recommended that you visit the original post to play with the interactive demos.

If you found this series helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks 😍. Your support would mean a lot to me!

If you want more helpful content like this, feel free to follow me:

Top comments (0)