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.