Creating a canvas based drawing app.
There is not that much to a drawing app. Listen to the mouse, when the button is down draw at the mouse position.
If you want to have a responsive canvas and also include undos and more then you need to start at a slightly more complex level.
Drawing and display.
First you should separate the drawing from the display. This is done by creating an offscreen canvas that holds the drawing. Its size is constant and can be panned and zoomed (even rotated) by the user.
Having an offscreen canvas to hold the drawing also lets you draw over the drawing if you are creating lines or boxes.
Some functions to aid in creating a canvas
function createCanvas(width, height) {
const c = document.createElement("canvas");
c.width = width;
c.height = height;
c.ctx = c.getContext("2d");
return c;
}
const drawing = createCanvas(512,512);
You can draw that canvas to the display canvas with
ctx.drawImage(drawing,0,0);
The snippet draws the drawing in the center of the canvas with a shadow and border to look nice.
The mouse handler
It is important that you do the mouse interface correctly. Listening to the mouse events from the canvas has some problems. When the user drags off of the canvas you no longer get any mouse events. This means that when the mouse leaves the canvas you need to stop drawing as you don't know if the mouse is still down when it returns.
To solve this problem you listen to the document's mouse events. This will capture the mouse when a button is down, allowing the user to move the mouse anywhere on the screen while you still get the events. If the mouse goes up while off the canvas you still get that event.
NOTE the stack overflow snippet window prevents mouse capture ATM (a recent change) so the above mouse behaviour is restricted to the iFrame containing the snippet.
You should also never do any rendering from mouse events. Some rendering operations can be slow, much slower than the update speed of the mouse. If you render from the mouse events you will lose mouse events. Always do the minimum possible code inside mouse event listeners.
In the snippet the mouse event only records the current mouse state and if the mouse is down and drawing it will record the path the mouse creates. A separate loop that is synced to the display refresh rate via the function call requestAnimationFrame
is responsible for rendering content. It runs at about 60fps. To stop it from drawing when nothing is happening a flag is used to indicate that the display needs updating updateDisplay
When there are changes you set that to true updateDisplay=true;
and the next time the display hardware is ready to display a frame it will draw all the updated content.
Drawing a line
A line is just a set of connected points. In the snippet I create a line object. It holds the points that make up the line and the line width and color.
When the mouse is down I create a new line object and start adding points to it. I flag that the display needs updating and in the display loop I draw the line on the display canvas via its draw method.
When the mouse moves up, then I draw the line on the drawing canvas. Doing it this way lets you apply some smarts to the line (snippet is simple and does nothing to the line), for example making it fade out along its length. You will only know its length when the mouse is up.
In the snippet I discard the line when the mouse is up, but if you wanted to have undos you would save each line drawn in an array. To undo you just clear the drawing and redraw all the lines except for the undone line.
The line object and associated code.
// a point object creates point from x,y coords or object that has x,y
const point = (x, y = x.y + ((x = x.x) * 0)) => ({ x, y });
// function to add a point to the line
function addPoint(x, y) { this.points.push(point(x, y)); }
// draw a line on context ctx and adds offset.x, offset.y
function drawLine(ctx, offset) {
ctx.strokeStyle = this.color;
ctx.lineWidth = this.width;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
var i = 0;
while (i < this.points.length) {
const p = this.points[i++];
ctx.lineTo(p.x + offset.x, p.y + offset.y);
}
ctx.stroke();
}
// creates a new line object
function createLine(color, width) {
return {
points: [], // the points making up the line
color, // colour of the line
width, // width of the line
add: addPoint, // function to add a point
draw: drawLine, // function to draw the whole line
};
}
Example snippet
The snippet turned out a little longer than I wanted. I have added comments for relevant code but if you have any question please do ask in the comments and I will update the answer with more information.
// size of drawing and its starting background colour
const drawingInfo = {
width: 384 ,
height: 160,
bgColor: "white",
}
const brushSizes = [1, 2, 3, 4, 5, 6, 7, 8];
const colors = "red,orange,yellow,green,cyan,blue,purple,white,gray,black".split(",");
var currentColor = "blue";
var currentWidth = 2;
var currentSelectBrush;
var currentSelectColor;
const colorSel = document.getElementById("colorSel");
colors.forEach((color, i) => {
var swatch = document.createElement("span");
swatch.className = "swatch";
swatch.style.backgroundColor = color;
if (currentColor === color) {
swatch.className = "swatch highlight";
currentSelectColor = swatch;
} else {
swatch.className = "swatch";
}
swatch.addEventListener("click", (e) => {
currentSelectColor.className = "swatch";
currentColor = e.target.style.backgroundColor;
currentSelectColor = e.target;
currentSelectColor.className = "swatch highlight";
});
colorSel.appendChild(swatch);
})
brushSizes.forEach((brushSize, i) => {
var brush = document.createElement("canvas");
brush.width = 16;
brush.height = 16;
brush.ctx = brush.getContext("2d");
brush.ctx.beginPath();
brush.ctx.arc(8, 8, brushSize / 2, 0, Math.PI * 2);
brush.ctx.fill();
brush.brushSize = brushSize;
if (currentWidth === brushSize) {
brush.className = "swatch highlight";
currentSelectBrush = brush;
} else {
brush.className = "swatch";
}
brush.addEventListener("click", (e) => {
currentSelectBrush.className = "swatch";
currentSelectBrush = e.target;
currentSelectBrush.className = "swatch highlight";
currentWidth = e.target.brushSize;
});
colorSel.appendChild(brush);
})
const canvas = document.getElementById("can");
const mouse = createMouse().start(canvas, true);
const ctx = canvas.getContext("2d");
var updateDisplay = true; // when true the display needs updating
var ch, cw, w, h; // global canvas size vars
var currentLine;
var displayOffset = {
x: 0,
y: 0
};
// a point object creates point from x,y coords or object that has x,y
const point = (x, y = x.y + ((x = x.x) * 0)) => ({
x,
y
});
// function to add a point to the line
function addPoint(x, y) {
this.points.push(point(x, y));
}
function drawLine(ctx, offset) { // draws a line
ctx.strokeStyle = this.color;
ctx.lineWidth = this.width;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
var i = 0;
while (i < this.points.length) {
const p = this.points[i++];
ctx.lineTo(p.x + offset.x, p.y + offset.y);
}
ctx.stroke();
}
function createLine(color, width) {
return {
points: [],
color,
width,
add: addPoint,
draw: drawLine,
};
}
// creates a canvas
function createCanvas(width, height) {
const c = document.createElement("canvas");
c.width = width;
c.height = height;
c.ctx = c.getContext("2d");
return c;
}
// resize main display canvas and set global size vars
function resizeCanvas() {
ch = ((h = canvas.height = innerHeight - 32) / 2) | 0;
cw = ((w = canvas.width = innerWidth) / 2) | 0;
updateDisplay = true;
}
function createMouse() {
function preventDefault(e) { e.preventDefault() }
const mouse = {
x: 0,
y: 0,
buttonRaw: 0,
prevButton: 0
};
const bm = [1, 2, 4, 6, 5, 3]; // bit masks for mouse buttons
const mouseEvents = "mousemove,mousedown,mouseup".split(",");
const m = mouse;
// one mouse handler
function mouseMove(e) {
m.bounds = m.element.getBoundingClientRect();
m.x = e.pageX - m.bounds.left - scrollX;
m.y = e.pageY - m.bounds.top - scrollY;
if (e.type === "mousedown") {
m.buttonRaw |= bm[e.which - 1];
} else if (e.type === "mouseup") {
m.buttonRaw &= bm[e.which + 2];
}
// check if there should be a display update
if (m.buttonRaw || m.buttonRaw !== m.prevButton) {
updateDisplay = true;
}
// if the mouse is down and the prev mouse is up then start a new line
if (m.buttonRaw !== 0 && m.prevButton === 0) { // starting new line
currentLine = createLine(currentColor, currentWidth);
currentLine.add(m); // add current mouse position
} else if (m.buttonRaw !== 0 && m.prevButton !== 0) { // while mouse is down
currentLine.add(m); // add current mouse position
}
m.prevButton = m.buttonRaw; // remember the previous mouse state
e.preventDefault();
}
// starts the mouse
m.start = function(element, blockContextMenu) {
m.element = element;
mouseEvents.forEach(n => document.addEventListener(n, mouseMove));
if (blockContextMenu === true) {
document.addEventListener("contextmenu", preventDefault)
}
return m
}
return m;
}
var cursor = "crosshair";
function update(timer) { // Main update loop
cursor = "crosshair";
globalTime = timer;
// if the window size has changed resize the canvas
if (w !== innerWidth || h !== innerHeight) {
resizeCanvas()
}
if (updateDisplay) {
updateDisplay = false;
display(); // call demo code
}
ctx.canvas.style.cursor = cursor;
requestAnimationFrame(update);
}
// create a drawing canvas.
const drawing = createCanvas(drawingInfo.width, drawingInfo.height);
// fill with white
drawing.ctx.fillStyle = drawingInfo.bgColor;
drawing.ctx.fillRect(0, 0, drawing.width, drawing.height);
// function to display drawing
function display() {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "rgba(0,0,0,0.25)";
const imgX = cw - (drawing.width / 2) | 0;
const imgY = ch - (drawing.height / 2) | 0;
// add a shadow to make it look nice
ctx.fillRect(imgX + 5, imgY + 5, drawing.width, drawing.height);
// add outline
ctx.strokeStyle = "black";
ctx.lineWidth = "2";
ctx.strokeRect(imgX, imgY, drawing.width, drawing.height);
// draw the image
ctx.drawImage(drawing, imgX, imgY);
if (mouse.buttonRaw !== 0) {
if (currentLine !== undefined) {
currentLine.draw(ctx, displayOffset); // draw line on display canvas
cursor = "none";
updateDisplay = true; // keep updating
}
} else if (mouse.buttonRaw === 0) {
if (currentLine !== undefined) {
currentLine.draw(drawing.ctx, {x: -imgX, y: -imgY }); // draw line on drawing
currentLine = undefined;
updateDisplay = true;
// next line is a quick fix to stop a slight flicker due to the current frame not showing the line
ctx.drawImage(drawing, imgX, imgY);
}
}
}
requestAnimationFrame(update);
#can {
position: absolute;
top: 32px;
left: 0px;
background-color: #AAA;
}
.colors {
border: 1px solid black;
display: inline-flex;
}
.swatch {
min-width: 16px;
min-height: 16px;
max-width: 16px;
border: 1px solid black;
display: inline-block;
margin: 2px;
cursor: pointer;
}
.highlight {
border: 1px solid red;
}
<canvas id="can"></canvas>
<div class="colors" id="colorSel"></div>
Update In response to the comment by the OP I have added a HTML version that you should be able to copy and paste (everything and including <!DOCTYPE HTML>
to </HTML>
) into a html document (eg drawing.html) and then open in a browser that supports ES6. eg Chrome, Firefox, Edge.
Copy content of snippet below.
<!DOCTYPE HTML>
<html>
<head>
<style>
#can {
position: absolute;
top: 32px;
left: 0px;
background-color: #AAA;
}
.colors {
border: 1px solid black;
display: inline-flex;
}
.swatch {
min-width: 16px;
min-height: 16px;
max-width: 16px;
border: 1px solid black;
display: inline-block;
margin: 2px;
cursor: pointer;
}
.highlight {
border: 1px solid red;
}
</style>
</head>
<body>
<canvas id="can"></canvas>
<div class="colors" id="colorSel"></div>
<script>
// size of drawing and its starting background colour
const drawingInfo = {
width: 384 ,
height: 160,
bgColor: "white",
}
const brushSizes = [1, 2, 3, 4, 5, 6, 7, 8];
const colors = "red,orange,yellow,green,cyan,blue,purple,white,gray,black".split(",");
var currentColor = "blue";
var currentWidth = 2;
var currentSelectBrush;
var currentSelectColor;
const colorSel = document.getElementById("colorSel");
colors.forEach((color, i) => {
var swatch = document.createElement("span");
swatch.className = "swatch";
swatch.style.backgroundColor = color;
if (currentColor === color) {
swatch.className = "swatch highlight";
currentSelectColor = swatch;
} else {
swatch.className = "swatch";
}
swatch.addEventListener("click", (e) => {
currentSelectColor.className = "swatch";
currentColor = e.target.style.backgroundColor;
currentSelectColor = e.target;
currentSelectColor.className = "swatch highlight";
});
colorSel.appendChild(swatch);
})
brushSizes.forEach((brushSize, i) => {
var brush = document.createElement("canvas");
brush.width = 16;
brush.height = 16;
brush.ctx = brush.getContext("2d");
brush.ctx.beginPath();
brush.ctx.arc(8, 8, brushSize / 2, 0, Math.PI * 2);
brush.ctx.fill();
brush.brushSize = brushSize;
if (currentWidth === brushSize) {
brush.className = "swatch highlight";
currentSelectBrush = brush;
} else {
brush.className = "swatch";
}
brush.addEventListener("click", (e) => {
currentSelectBrush.className = "swatch";
currentSelectBrush = e.target;
currentSelectBrush.className = "swatch highlight";
currentWidth = e.target.brushSize;
});
colorSel.appendChild(brush);
})
const canvas = document.getElementById("can");
const mouse = createMouse().start(canvas, true);
const ctx = canvas.getContext("2d");
var updateDisplay = true; // when true the display needs updating
var ch, cw, w, h; // global canvas size vars
var currentLine;
var displayOffset = {
x: 0,
y: 0
};
// a point object creates point from x,y coords or object that has x,y
const point = (x, y = x.y + ((x = x.x) * 0)) => ({
x,
y
});
// function to add a point to the line
function addPoint(x, y) {
this.points.push(point(x, y));
}
function drawLine(ctx, offset) { // draws a line
ctx.strokeStyle = this.color;
ctx.lineWidth = this.width;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
var i = 0;
while (i < this.points.length) {
const p = this.points[i++];
ctx.lineTo(p.x + offset.x, p.y + offset.y);
}
ctx.stroke();
}
function createLine(color, width) {
return {
points: [],
color,
width,
add: addPoint,
draw: drawLine,
};
}
// creates a canvas
function createCanvas(width, height) {
const c = document.createElement("canvas");
c.width = width;
c.height = height;
c.ctx = c.getContext("2d");
return c;
}
// resize main display canvas and set global size vars
function resizeCanvas() {
ch = ((h = canvas.height = innerHeight - 32) / 2) | 0;
cw = ((w = canvas.width = innerWidth) / 2) | 0;
updateDisplay = true;
}
function createMouse() {
function preventDefault(e) { e.preventDefault() }
const mouse = {
x: 0,
y: 0,
buttonRaw: 0,
prevButton: 0
};
const bm = [1, 2, 4, 6, 5, 3]; // bit masks for mouse buttons
const mouseEvents = "mousemove,mousedown,mouseup".split(",");
const m = mouse;
// one mouse handler
function mouseMove(e) {
m.bounds = m.element.getBoundingClientRect();
m.x = e.pageX - m.bounds.left - scrollX;
m.y = e.pageY - m.bounds.top - scrollY;
if (e.type === "mousedown") {
m.buttonRaw |= bm[e.which - 1];
} else if (e.type === "mouseup") {
m.buttonRaw &= bm[e.which + 2];
}
// check if there should be a display update
if (m.buttonRaw || m.buttonRaw !== m.prevButton) {
updateDisplay = true;
}
// if the mouse is down and the prev mouse is up then start a new line
if (m.buttonRaw !== 0 && m.prevButton === 0) { // starting new line
currentLine = createLine(currentColor, currentWidth);
currentLine.add(m); // add current mouse position
} else if (m.buttonRaw !== 0 && m.prevButton !== 0) { // while mouse is down
currentLine.add(m); // add current mouse position
}
m.prevButton = m.buttonRaw; // remember the previous mouse state
e.preventDefault();
}
// starts the mouse
m.start = function(element, blockContextMenu) {
m.element = element;
mouseEvents.forEach(n => document.addEventListener(n, mouseMove));
if (blockContextMenu === true) {
document.addEventListener("contextmenu", preventDefault)
}
return m
}
return m;
}
var cursor = "crosshair";
function update(timer) { // Main update loop
cursor = "crosshair";
globalTime = timer;
// if the window size has changed resize the canvas
if (w !== innerWidth || h !== innerHeight) {
resizeCanvas()
}
if (updateDisplay) {
updateDisplay = false;
display(); // call demo code
}
ctx.canvas.style.cursor = cursor;
requestAnimationFrame(update);
}
// create a drawing canvas.
const drawing = createCanvas(drawingInfo.width, drawingInfo.height);
// fill with white
drawing.ctx.fillStyle = drawingInfo.bgColor;
drawing.ctx.fillRect(0, 0, drawing.width, drawing.height);
// function to display drawing
function display() {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "rgba(0,0,0,0.25)";
const imgX = cw - (drawing.width / 2) | 0;
const imgY = ch - (drawing.height / 2) | 0;
// add a shadow to make it look nice
ctx.fillRect(imgX + 5, imgY + 5, drawing.width, drawing.height);
// add outline
ctx.strokeStyle = "black";
ctx.lineWidth = "2";
ctx.strokeRect(imgX, imgY, drawing.width, drawing.height);
// draw the image
ctx.drawImage(drawing, imgX, imgY);
if (mouse.buttonRaw !== 0) {
if (currentLine !== undefined) {
currentLine.draw(ctx, displayOffset); // draw line on display canvas
cursor = "none";
updateDisplay = true; // keep updating
}
} else if (mouse.buttonRaw === 0) {
if (currentLine !== undefined) {
currentLine.draw(drawing.ctx, {x: -imgX, y: -imgY }); // draw line on drawing
currentLine = undefined;
updateDisplay = true;
// next line is a quick fix to stop a slight flicker due to the current frame not showing the line
ctx.drawImage(drawing, imgX, imgY);
}
}
}
requestAnimationFrame(update);
/* load and add image to the drawing. It may take time to load. */
function loadImage(url){
const image = new Image();
image.src = url;
image.onload = function(){
if(drawing && drawing.ctx){
drawing.width = image.width;
drawing.height = image.height;
drawing.ctx.drawImage(image,0,0);
};
}
}
loadImage("https://i.stack.imgur.com/C7qq2.png?s=328&g=1");
</script>
</body>
</html>