CSS

Creating a Javascript Drawing and Annotation Application

📣 Sponsor

Annotation and drawing have many use cases. In this tutorial let's look at how to make a simple drawing application with Javascript. We'll be building this in pure Javascript, so it will give you the flexibility to implement it wherever you need to.

Demo

To get started, let's look at how it works, I've added functionality to this page. When you click the button, you will be provided drawing tools, which you can use to draw on the page with lines and arrows. There is also an eraser tool, to erase lines by clicking on them should you make mistakes.

The drawing tools can be closed by clicking the cross button within the drawing box. As well, all drawings are maintained on the page afterward, allowing you to annotate this page.

Tracking User's Mouse Activity

As mentioned before, this demo uses vanilla JS, so to make it work we focus on using a few event listeners which will track what the user is doing.

To track user activity, I have created 3 objects - one is a general config item, and the other two relate to the pencil and arrow drawing tools respectively. These configs are shown below along with explanations for each item in the comments.

let config = { drawing: false, // Set to true if we are drawing, false if we aren't tool: 'freeHand', // The currently selected tool color : 'white', // The currently selected colour strokeWidth: 4, // The width of the lines we draw configNormalisation: 12,// The average normalisation for pencil drawing } let arrow = { // topX, Y, and bottomX, Y store information on the arrows top and bottom ends topX: 0, topY: 0, bottomX: 0, bottomY: 0, activeDirection: 'se', // This is the current direction of the arrow, i.e. south-east arrowClasses: [ 'nw', 'ne', 'sw', 'se' ], // These are possible arrow directions lineAngle: 0, // This is the angle the arrow point at about the starting point } let freeHand = { currentPathText: 'M0 0 ', // This is the current path of the pencil line, in text topX: 0, // The starting X coordinate topY: 0, // The starting Y coordinate lastMousePoints: [ [0, 0] ], // This is the current path of the pencil line, in array }

Controlling the UI

The next major step is adding a UI. I've created a simple drawing UI which pops up when the user clicks on 'Start Drawing'. In HTML, it looks like this:

<button class="animated hover-button" id="start-drawing"><span>✍️ Activate Drawing</span></button> <div id="drawing-cover"></div> <div id="drawing-layer"></div> <div id="drawing-box"> <div class="tools"> <button data-tool="freeHand" data-current="true"><span>Pen</span></button> <button data-tool="arrow"><span>Arrow</span></button> <button data-tool="eraser"><span>Eraser</span></button> </div> <div class="colors"> <div data-color="white" data-rColor="white" data-current="true"></div> <div data-color="black" data-rColor="#544141"></div> <div data-color="red" data-rColor="#d83030"></div> <div data-color="green" data-rColor="#30d97d"></div> <div data-color="orange" data-rColor="#ff9000"></div> <div data-color="yellow" data-rColor="f3f326"></div> </div> <div class="close"> Close </div> </div>

Note: I have added two other elements which sit outside the UI - one is drawing-cover, which simply covers the screen whenever drawing is activated, and the other is drawing-layer, which holds all drawing elements the user draws.

For each tool and color, I have appended some data attributes:

  • For tools - each item has a data-tool attribute.
  • For colors - each item has a data-color and data-rColor attribute, referring to the color's textual name and its hex value respectively.

We need data attributes because we will reference them in the code when the user clicks on items. Below is the code for controlling the UI - in effect, all we do here is alter our main config object when the user clicks on a tool or color:

// Add a pointerdown event for each color and tool. // When a user clicks a color or tool, then we set it to our current config.color or config.tool respectively, and highlight it on the UI [ 'data-rColor', 'data-tool' ].forEach(function(i) { document.querySelectorAll(`[${i}]`).forEach(function(item) { item.addEventListener('pointerdown', function(e) { document.querySelectorAll(`[${i}]`).forEach(function(i) { i.setAttribute('data-current', false); }); item.setAttribute('data-current', true); if(i == 'data-rColor') { config.color = item.getAttribute(i); } else if(i == 'data-tool') { config.tool = item.getAttribute(i); } }); }); }); // Set the body attribute 'data-drawing' to true or false, based on if the user clicks the 'Start Drawing' button // Also sets config.drawing to true or false. document.getElementById('start-drawing').addEventListener('click', function(e) { if(config.drawing === true) { config.drawing = false; document.body.setAttribute('data-drawing', false) } else { let drawingCover = document.getElementById('drawing-cover'); document.body.setAttribute('data-drawing', true) config.drawing = true; } }); // Closes the drawing box and sets 'data-drawing' on the body element to false // Along with cofig.drawing to false. document.querySelector('#drawing-box .close').addEventListener('click', function(e) { document.body.setAttribute('data-drawing', false); config.drawing = false; })

HTML Elements for Our Drawings

To draw, we must append something to our user's screen. To make things easy and modular, I am using SVG. Based on your use case, this could also be rewritten with canvas if required - however SVG works fine too. SVG gives us a good amount of control over the DOM elements should we need it. I have created two functions that return HTML for arrows and line drawings. These are shown below.

As we need to pass some numbers to these HTML elements, we have variables for these functions (i.e. start, dimensions, path, etc.. This allows us to give updated positioning to our SVG tags, whch we can render onto the page.

let svgEl = { arrowPath: (start, dimensions, path, dummy, direction, end, angle, hyp, id) => `<div class="arrow drawing-el static current-item" data-id="${id}" data-direction="${direction}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;"> <div class="arrow-point arrow-point-one"></div> <div class="arrow-point arrow-point-two" style=" transform-origin: 0 0; left: ${hyp[1]}px; top: ${hyp[2]}px; transform: rotateZ(${angle}deg) translateY(-${hyp[0]}px) translateX(-15px); "></div> <svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}"> <defs> <marker id="arrow-head-${id}" class="arrow-resizer" markerWidth="10" markerHeight="10" refX="0" refY="3" orient="auto" markerUnits="strokeWidth" viewBox="0 0 20 20"> <path d="M0 0 L0 6 L9 3 z" fill="${config.color}" /> </marker> </defs> <path marker-start="url(#bottom-marker)" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" marker-end="url(#arrow-head-${id})" class="arrow-line" d="${path}" /> </svg> </div>`, drawPath: (start, dimensions, path, id) => `<div class="free-hand drawing-el static current-item" data-id="${id}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;"> <svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}"> <path d="${path}" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" /> </svg> </div>` }

These two functions give us the right SVGs for arrows and free hand drawing.

User Interaction

The final step is adding in user interaction. Ultimately, this boils down to three main functions:

  • a mousedown, to tell us when the user has initiated drawing, assuming they have clicked the 'Start Drawing' button.
  • a mousemove, to track a user's mouse movement.
  • a mouseup, for when the user completes drawing.

Step 1: Mousedown

The first stage is mousedown. This will trigger any time the user clicks on our webpage. As such, we want to make sure the user is drawing (i.e. config.drawing is true). We can do that by checking our config object to see if config.drawing is set to true. If we are drawing, then we store the initial point the user clicks in either the freeHand or arrow config objects.

Finally, we append the HTML elements to the page. If we're using the eraser we check if the point the user clicked on is an SVG and delete it if clicked on. For that, we use the parent helper function, which can be found in the Github Repo or on our ">Codepen example.

document.body.addEventListener('pointerdown', function(e) { // Generate id for each element let id = helper.generateId(); if(config.tool == 'arrow' && config.drawing == true) { // Set arrow start point arrow.topX = e.clientX; arrow.topY = e.clientY; // Add element to drawing layer document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + svgEl.arrowPath( [ arrow.topX + window.scrollX, arrow.topY + window.scrollY ], [ e.clientX, e.clientX ], `M0 0 L0 0`, 'arrow-item', arrow.arrowClasses[3], [ 0, 0 ], 0, [ 0, 0, 0 ], id ); } else if(config.tool == 'freeHand' && config.drawing == true) { // Set the drawing starting point freeHand.topX = e.clientX; freeHand.topY = e.clientY; // Set the current path and most recent mouse points to whereever we are scrolled on the page freeHand.currentPathText = `M${window.scrollX} ${window.scrollY} `; freeHand.lastMousePoints = [[ window.scrollX, window.scrollY ]]; // Add element to the drawing layer document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + svgEl.drawPath( [ e.clientX, e.clientY ], [ e.clientX, e.clientY ], ``, id); } else if(config.tool == 'eraser' && config.drawing == true) { // Check if user has clicked on an svg if(helper.parent(e.target, '.drawing-el', 1) !== null && helper.parent(e.target, '.drawing-el', 1).matches('.drawing-el')) { // If they have, delete it helper.parent(e.target, '.drawing-el', 1).remove(); } } })

Step 2: Mousemove

Next let's work on what happens whenever the user has clicked, and then moves their mouse. In this situation, we want to extend the line for free hand, or move the arrow head for arrows. The currently drawn element has a class called current-item, so we can use this to update our HTML element. Fundamentally, we just want to add more points to our SVG element based on where the user's mouse goes. Since we stored the original position the user clicked in our config we can use this as a reference point to figure out how many pixels the user has moved from there. To do that, we also use two calculation helper functions, both of which can be found in the Github Repo or on our codepen example:

  • For arrows, we use calculateArrowLineAngle to calculate the angle and direction of the arrow.
  • For free hand, we use getAveragePoint to calculate the average of the last few mouse movements, to create a smooth line.

Upon moving, we also remove the class static from the drawn elements. This lets us know the user wants to keep this drawn element. If they didn't move, we would later remove it when they lift their finger off the mouse, and the static class lets us determine that.

document.body.addEventListener('pointermove', function(e) { // Assuming there is a current item to in the drawing layer if(document.querySelector('#drawing-layer .current-item') !== null) { // If we are using the arrow tool if(config.drawing == true && config.tool == 'arrow') { // Then get the original start position let startX = arrow.topX; let startY = arrow.topY; // Set a default angle of 90 let angleStart = 90; // And a default direction of 'south east' let arrowClass = arrow.arrowClasses[3]; // Calculate how far the user has moved their mouse from the original position let endX = e.pageX - startX - window.scrollX; let endY = e.pageY - startY - window.scrollY; // And using that info, calculate the arrow's angle helper.calculateArrowLineAngle(endX, endY); // Then update the config to this new end position arrow.bottomX = endX; arrow.bottomY = endY; // And update the HTML to show the new arrow to the user document.querySelector('#drawing-layer .arrow.current-item').classList.remove('static'); document.querySelector('#drawing-layer .arrow.current-item').setAttribute('data-direction', arrow.activeDirection); document.querySelector('#drawing-layer .arrow.current-item svg').setAttribute('viewbox', `0 ${endX} 0 ${endY}`); document.querySelector('#drawing-layer .arrow.current-item path.arrow-line').setAttribute('d', `M0 0 L${endX} ${endY}`); } else if(config.drawing == true && config.tool == 'freeHand') { // Similar to arrows, calculate the user's end position let endX = e.pageX - freeHand.topX; let endY = e.pageY - freeHand.topY; // And push these new coordinates to our config let newCoordinates = [ endX, endY ]; freeHand.lastMousePoints.push([endX, endY]); if(freeHand.lastMousePoints.length >= config.configNormalisation) { freeHand.lastMousePoints.shift(); } // Then calculate the average points to display a line to the user let avgPoint = helper.getAveragePoint(0); if (avgPoint) { freeHand.currentPathText += " L" + avgPoint.x + " " + avgPoint.y; let tmpPath = ''; for (let offset = 2; offset < freeHand.lastMousePoints.length; offset += 2) { avgPoint = helper.getAveragePoint(offset); tmpPath += " L" + avgPoint.x + " " + avgPoint.y; } // Set the complete current path coordinates document.querySelector('#drawing-layer .free-hand.current-item').classList.remove('static'); document.querySelector('#drawing-layer .free-hand.current-item svg path').setAttribute('d', freeHand.currentPathText + tmpPath); } } } });

Step 3: Mouseup

The point of mouse up is to a) reset the drawing configuation for freeHand and arrow and b) remove any elements where the user didn't move their mouse. If we don't do b), then random arrow heads will appear as the user clicks on the page.

This is relatively simple compared to the other functions, and looks like this:

// Whenever the user leaves the page with their mouse or lifts up their cursor [ 'mouseleave', 'pointerup' ].forEach(function(item) { document.body.addEventListener(item, function(e) { // Remove current-item class from all elements, and give all SVG elements pointer-events document.querySelectorAll('#drawing-layer > div').forEach(function(item) { item.style.pointerEvent = 'all'; item.classList.remove('current-item'); // Delete any 'static' elements if(item.classList.contains('static')) { item.remove(); } }); // Reset freeHand variables where needed freeHand.currentPathText = 'M0 0 '; freeHand.lastMousePoints = [ [0, 0] ]; }); });

Conclusion

And we're done. Since we've used pointerdown, pointermove and pointerup, this demo should also work on mobile. Below, I've attached some useful links, including the source code on Github and Codepen. If you have any questions, you can reach us on Twitter.

Last Updated 1633471811003

More Tips and Tricks for CSS

Subscribe for Weekly Dev Tips

Subscribe to our weekly newsletter, to stay up to date with our latest web development and software engineering posts via email. You can opt out at any time.

Not a valid email