Blog Post

How to Build a Freehand Drawing Using React

Illustration: How to Build a Freehand Drawing Using React

At PSPDFKit, we make the most advanced PDF SDKs for mobile and desktop. We released our Web PDF SDK in December 2016 and are working hard to bring all the beloved PSPDFKit features from iOS and Android to the browser.

We’d like to give you some insights into how we built these features. For this blog post, we’ll look at how we implemented one of the PDF annotation features: freehand drawing.

High-Level Goal

The high-level goal we sought to achieve was to develop a useful technique when implementing a feature that’s difficult to estimate. In this case, we want the user to be able to draw multiple lines inside an HTML element using their mouse. Lines should be connected until the mouse button is released again. Here’s how it could look:

We want to encapsulate the whole feature inside a React component so we can reuse the code whenever we need it again.

Investigating the Canvas Element

When we thought about freehand drawing on the web and did some research, we quickly discovered the <canvas> element. The <canvas> element is a drawing canvas that can be controlled via JavaScript. That seemed pretty good for our use case! Here’s a quick example of the API:

let canvas = document.getElementById('canvas');
let ctx = c.getContext('2d');
ctx.moveTo(0, 0);
ctx.lineTo(200, 100);
ctx.stroke();

The canvas API is pretty straightforward. We created a drawable context using the getContext() method and used low-level drawing instructions to make lines. In the example above, we moved the cursor to point 0 0 (starting from the top, left corner) and drew a line to 200 100.

While this seems like a perfect fit, there are two downsides to using <canvas> in our example:

  1. The API requires an HTML element — something that’s possible in React using refs — but we need to think carefully about how this will interact with React’s lifecycle methods. When the render() method is called, remember that React only generates a representation of the DOM. We need to defer the drawing instructions until the element is actually created (i.e. on componentDidMount() / componentDidUpdate()).

  2. The canvas element is a bitmap and thus will be rasterized before being rendered on the screen. That means that drawing is done pixel-wise with the exact coordinates you supply. When we apply a transformation to the canvas and scale it after the drawing, it’ll appear blurry. If only there were a vector graphic format for the web…

Meet SVG

SVG is an XML-based vector image format with easy-to-use primitives for all basic shapes: lines, circles, rectangles, etc. It’s embedded into HTML by just placing it into an <svg> tag, like this:

<div>
	<svg>
		<path stroke="black" d="M 0 0 L 200 100" />
	</svg>
</div>

This example also renders a line from 0 0 to 200 100. The information is encoded inside the d property of the <path> (we’ll talk about this special property later). Setting the stroke color can be done either as an element property or by using CSS.

Because of its XML-based format, it’s handled by React the same way we handle HTML. We can declaratively render the elements inside our render() methods and let React take care of applying the changes to the DOM efficiently.

Component and Data Structure

Before we begin, we need to think about the component structure for a second. We already discussed that we want to encapsulate the complete feature inside a single React component. We’ll call this component DrawArea. Its main purpose is to handle mouse events. Inside the DrawArea, we’ll place a Drawing to abstract the SVG logic away. It’ll receive the points to draw as props. Finally, the Drawing will, for every line, render the individual lines using DrawingLine. Since Drawing and DrawingLine won’t have their own state, we’ll use functional components for them.

The state of our DrawArea will contain a Boolean isDrawing that we’ll set to true when we start drawing. The lines property will be a list of lines, where a line contains, again, a list of points. We’ll use a map with x and y keys to represent a point.

We’ll use Immutable.js to handle the complex lines structure. While this isn’t a requirement for this task, we’ve found Immutable.js extremely useful in handling more complex state objects, since it comes with helpers that allow you to apply deep persistent changes.

DrawArea

We’ll start by initializing the state within the constructor of this component by setting lines to an empty list (thus, having no lines when we start) and isDrawing to false:

class DrawArea extends React.Component {
	constructor() {
		super();
		this.state = {
			isDrawing: false,
			lines: Immutable.List(),
		};
	}
}

We follow this by implementing the render() method and adding event handlers for onMouseDown. We’ll need a reference to the DOM element later, so let’s also add a ref attribute here:

render() {
  return (
    <div ref="drawArea" onMouseDown={this.handleMouseDown} />
  );
}

Next up, we implement the handleMouseDown method. It receives a MouseEvent as the parameter, which we can use to query the x and y coordinates. Since those coordinates start at the top-left corner of the browser and not our DrawArea, we’ll subtract the top and left position to receive relative coordinates.

We also have the option of using either MouseEvent.clientX or MouseEvent.screenX. The latter will reference the current window as the starting anchor and will change when you scroll on that window. The client coordinates, however, will be inside the client space and thus will stay the same if you scroll. We’ll use clientX, since this is the value we need when subtracting the boundingClientRect.

When we receive a mousedown event (and the left mouse button is clicked), we want to create a new line by pushing a new list with the current point to the line. We’ll update the state using the updater function approach: When we pass a function to setState(), React will call it with the current state and expect it to return the new state. The setState() happens atomically, so no updates are lost:

handleMouseDown(mouseEvent) {
  if (mouseEvent.button != 0) {
    return;
  }

  const point = this.relativeCoordinatesForEvent(mouseEvent);

  this.setState(prevState => {
    return {
      lines: prevState.lines.push(Immutable.List([point])),
      isDrawing: true,
    };
  });
}

relativeCoordinatesForEvent(mouseEvent) {
  const boundingRect = this.refs.drawArea.getBoundingClientRect();
  return new Immutable.Map({
    x: mouseEvent.clientX - boundingRect.left,
    y: mouseEvent.clientY - boundingRect.top,
  });
}

When we’re currently drawing and receive a mousemove event within the container, we want to push those points to the latest line. We implement this the same way as we implement mousedown: We add an event listener to the <div> and implement a handleMouseMove method. The state transition here uses a deep persistence change helper (updateIn()) from Immutable.js. The array is a path to the property we want to change — in our case, it’s the latest line. For that element, it’ll invoke a callback function, which we use to push the point into that segment:

handleMouseMove(mouseEvent) {
  if (!this.state.isDrawing) {
    return;
  }

  const point = this.relativeCoordinatesForEvent(mouseEvent);

  this.setState(prevState => {
    return {
      lines: prevState.lines.updateIn([prevState.lines.size - 1], line => line.push(point)),
    };
  });
}

To stop drawing, we need to do the same thing for the mouseup event. However, since it’s possible to start drawing inside the element, move outside, and release the mouse button outside as well, we need a way to track the mouseup events from all possible places. Luckily, we can add our custom event listeners to the document. They’ll fire even when the mouseup occurs outside the browser window. (Always keep in mind that you shouldn’t use custom event listeners if you don’t have to, since they’ll escape React’s event system, which can cause subtle problems when you try to stop propagation. We don’t use that for this feature, so we can ignore that for now.)

componentDidMount() {
  document.addEventListener("mouseup", this.handleMouseUp);
}
componentWillUnmount() {
  document.removeEventListener("mouseup", this.handleMouseUp);
}
handleMouseUp() {
  this.setState({ isDrawing: false });
}

This is enough to record the freehand drawings! All that’s left to do is implement the actual rendering of the drawing. As we discussed earlier, we want to use SVG to make this happen.

Drawing and DrawingLine

We start out with the Drawing component. It’ll render the <svg> and a DrawingLine for every line:

function Drawing({ lines }) {
	return (
		<svg>
			{lines.map((line, index) => (
				<DrawingLine key={index} line={line} />
			))}
		</svg>
	);
}

That was pretty straightforward! We just map over the lines and create a new DrawingLine for each one. We also add a key attribute here, which is required by React for identifying which DOM nodes have changed.

As a final step, the only thing that’s missing is the actual line. We already saw how SVG can render lines using the <path> element with a d property. Inside this property, the path data, we can instrument the path. We’re using two commands in our example:

  1. M x y — Moves the cursor to a coordinate.

  2. L x y — Draws a line from the current cursor to the new coordinate. The cursor will be set to the new coordinate.

Both of these commands require absolute points within the SVG (the x and y parts refer to the x and y coordinates in pixels). Those instructions will then be joined into a string, which we pass as the d property.

So what’s left for us to do is build this d property based on the list of points in a line. We’ll use a combination of .map() and .join() to achieve this, where .map() brings every point inside the required SVG format, and .join() combines those points to a string using a glue string:

function DrawingLine({ line }) {
	const pathData =
		'M ' + line.map((p) => p.get('x') + ' ' + p.get('y')).join(' L ');

	return <path d={pathData} />;
}

This is already enough to display the lines! Whenever we draw now, the state from DrawArea will update, which will cause the underlying Drawing to rerender. The browser is fast enough that this happens at 60fps.

Conclusion

We managed to set up a freehand drawing prototype in React with three components and a simple data structure by hooking into three mouse events. The complete state is handled by a single component, and drawing is split between the other two. Check out the complete example in this codepen.

This is exactly how we started with freehand ink PDF annotations for our React PDF library, which now comes with more than 30-out-of-the box features and has well-documented APIs to handle advanced use cases. I recommend using the free trial of our PDF library and checking out our Web PDF SDK demo to see all the new functionality we’ve built into line drawing — like resizing, dragging, line simplification, and even a custom cursor that increases its size when you increase the stroke width! 😎

Related Products
Share Post
Free 60-Day Trial Try PSPDFKit in your app today.
Free Trial

Related Articles

Explore more
PRODUCTS  |  Web • Releases • Components

PSPDFKit for Web 2024.3 Features New Stamps and Signing UI, Export to Office Formats, and More

PRODUCTS  |  Web • Releases • Components

PSPDFKit for Web 2024.2 Features New Unified UI Icons, Shadow DOM, and Tab Ordering

PRODUCTS  |  Web

Now Available for Public Preview: New Document Authoring Experience Provides Glimpse into the Future of Editing