canvas
element to build a simple browser-based drawing tool. In this final post we look at allowing users to add text annotations to the canvas.
Introduction
Welcome to the fourth (and final) post in this series, the goal of which is to build a canvas
-based drawing tool
from scratch with zero dependencies.
The tool should allow the user to upload an existing image and to embellish it with free-hand drawing, fixed shapes and text
annotations before exporting the image again. This tool is being built for instructional purposes, but for convenience I have
made it available here, if you would like to have a play around.
If you intend to follow the tutorial for yourself I would encourage you to grab the source code from the GitHub repo. The version of the code we build today will extend upon what was covered in the previous tutorials of this seres: Part I, Part II and Part III. Take a look back over these earlier tutorials if you come across any concepts or constructs that look unfamiliar.
In this post we will look at implementing functionality which allows the user to add text captions to their canvas.
Let's recap what we were planning to implement and where we are, currently:
- Draw free hand lines on the canvas (Part I)
- Draw resizable rectangles to highlight a section of the image (Part II)
- Set a background image on the canvas that we can annotate (Part III)
- Add text captions
The source code for the version of the tool that we build in this tutorial can be found on this branch of the GitHub repo, and this video briefly demonstrates the functionality we hope to build here:
Page markup and initial setup
The markup from the previous tutorials will, once again, need to be extended to include a few more buttons to
allow the user to add and edit text captions.
As part of this work we have reorganised the page layout, however we will focus only on the new elements relating to our
text-input functionality. A relevant extract from index.html
is shown here:
…
<div id="tools" class="control_panel_section">
<h3>Tools</h3>
<div class="control_option">
<button class="btn tool-btn with-context-menu" id="draw_tool_btn" data-active="false" data-target="pencil-controls">✎</button>
<button class="btn tool-btn" id="erase_tool_btn" data-active="false"><div class="erase_rect">▭</div></button>
<button class="btn tool-btn with-context-menu" id="rect_tool_btn" data-active="false" data-target="shape-controls">⊞</button>
<button class="btn tool-btn" id="selector_tool_btn" data-active="false"><div class="hand_pointer">☞</div></button>
<button class="btn tool-btn with-context-menu" id="text_tool_btn" data-active="false" data-target="text-controls">T</button>
</div>
<div id="context-menu">
<!-- Pencil controls -->
<div id="pencil-controls" class="control_option" style="display: none">
…
</div>
<!-- Rectangle controls -->
<div id="shape-controls" class="control_option" style="display: none">
…
</div>
<!-- Text controls -->
<div id="text-controls" class="control_option" style="display: none">
<label>
<div>Size:</div>
<select name="font_size" id="font_size">
<option value="10">XS</option>
<option value="12">S</option>
<option value="16" selected="">M</option>
<option value="22">L</option>
<option value="32">XL</option>
</select>
</label>
<label>
<div>Colour:</div>
<select name="font_colour" id="font_colour">
<option value="black" selected="">Black</option>
<option value="white">White</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
</label>
<label>
<div>Background:</div>
<select name="background_colour" id="background_colour">
<option value="white" selected="">White</option>
<option value="black">Black</option>
<option value="transparent">Transparent</option>
</select>
</label>
</div>
</div>
</div>
…
<script src="js/page.js"></script>
<script src="js/image.js"></script>
<script src="js/eraser.js"></script>
<script src="js/pencil.js"></script>
<script src="js/rectangle.js"></script>
<script src="js/shape.js"></script>
<script src="js/text_box.js"></script>
<script src="js/text.js"></script>
We have added a new tool button, text_tool_btn
, which will activate the text-input tool. We have also added a few
input elements within the text-controls
context menu. These new input elements should allow the user to alter the
font-size, font-colour and background-colour of the text caption.
Asides from the new HTML elements on the page, you can see that we are now loading two additional scripts from
js/text_box.js
and js/text.js
. The script at js/text.js
will declare a
TEXT
property on the global window
object, and the top-level page initialization script (at
js/page.js
) will initialize this TEXT
module along with the previously-implemented modules:
page.init = function(canvas_id){
// Initialize canvas size
page.canvas = document.getElementById(canvas_id);
page.canvas.width = window.getComputedStyle(page.canvas, null)
.getPropertyValue("width")
.replace(/px$/, '');
page.canvas.height = window.getComputedStyle(page.canvas, null)
.getPropertyValue("height")
.replace(/px$/, '');
page.ctx = this.canvas.getContext('2d');
init_global_button_handlers();
if(typeof window.PENCIL !== "undefined"){
window.PENCIL.init(page.ctx);
}
if(typeof window.IMAGE !== "undefined"){
window.IMAGE.init(page.ctx);
}
if(typeof window.ERASER !== "undefined"){
window.ERASER.init(page.ctx);
}
if(typeof window.SHAPE !== "undefined"){
window.SHAPE.init(page.ctx);
}
if(typeof window.TEXT !== "undefined"){
window.TEXT.init(page.ctx);
}
};
With this bit of bootstrapping carried out we can now take a look at what the TEXT
module actually does when we initialize it.
Initializing text-input behaviours
As with our previous modules, the TEXT
module is defined using the
revealing module pattern.
When we call the init
method on this module we are doing the following:
let ctx = null,
canvas = null,
p = null,
all_texts = [],
font_colour = "black",
font_size = 16,
background_colour = "white";
…
text.init = function(context){
ctx = context;
canvas = context.canvas;
// Initialize touch point state
p = new Point({ x: 0, y: 0, canvas: canvas })
init_text_style_handlers();
document.getElementById("text_tool_btn").addEventListener("click", throttle(function(e){
const $target = e.target.closest(".tool-btn"),
active = ($target.dataset.active==="true");
$target.dataset.active = !active;
toggle_text_handlers(!active);
PAGE.toggle_context_menu($target, !active);
}, 50));
};
…
const init_text_style_handlers = function(){
const $font_size_select = document.getElementById("font_size"),
$font_colour_select = document.getElementById("font_colour"),
$background_colour_select = document.getElementById("background_colour");
$font_size_select.addEventListener("change", function(e){
font_size = parseInt(e.target.value, 10);
all_texts.forEach(function(text){
if(text.is_active){
text.font_size = font_size;
text.draw();
}
);
…
};
At the top-level of the module you can see that we maintain the state of the text-input through a number of variables. The
font_size
, font_colour
and background_colour
are all intialized with default values
and we also maintain an all_texts
array, which is initially empty. We will use this array to store a reference to each
text caption we place on our canvas.
Within the init
method we initialize the reference to the canvas
element and its associated
rendering context (ctx
)
which is passed as an argument when the init
method is invoked. Also within the init
method
we build a new
Point
object, which is used to convert the location of our user interactions to and from the canvas coordinates. As part of the initialization we also invoke
init_text_style_handlers
, which is simply responsible for binding to change events on the font-style, font-colour and
background-colour select
tags. For example:
const init_text_style_handlers = function(){
const $font_size_select = document.getElementById("font_size"),
$font_colour_select = document.getElementById("font_colour"),
$background_colour_select = document.getElementById("background_colour");
$font_size_select.addEventListener("change", function(e){
font_size = parseInt(e.target.value, 10);
all_texts.forEach(function(text){
if(text.is_active){
text.font_size = font_size;
text.draw();
}
});
…
};
In the code snippet above we limit consideration to handling font-size changes. The change event on the select
element is used to set the corresponding module-level state (font_size
in this case). We then loop over the
all_texts
array and redraw with the new font-size for any text-element that is flaggeded as currently active.
The final component in the TEXT.init
function is setting up the click-handler on the text_tool_btn
.
Clicking this button will execute the following:
- Toggle the
data-active
attribute on the button, which tracks whether the tool is active/inactive - Invoke the
toggle_text_handlers
function, which we will examine shortly - Delegate to the
PAGE
module to show (or hide) the context menu associated with text input
toggle_text_handlers
:
const toggle_text_handlers = function(on) {
const method = on ? canvas.addEventListener : canvas.removeEventListener;
method.call(canvas, 'mouseup', mouseup);
method.call(canvas, 'touchend', touchend);
};
The toggle_text_handlers
is used to either add handlers to, or remove handlers from the canvas element.
Whether we are adding or removing is controlled by the boolean value of the on
argument.
The canvas element will listen to mouseup
and touchend
events, but they are basically doing the
same thing, so let's focus on the handling of touchend
events:
const touchend = function(event){
event.preventDefault();
if (event.changedTouches.length == 1) {
const touch = event.changedTouches[0];
p.x = touch.pageX;
p.y = touch.pageY;
activate_or_create_text_box(p);
}
};
We first verify that we are dealing with a single touch point, then we capture the pageX
and pageY
coordinates of
this touch event in our Point
object (p
), which we then pass to the activate_or_create_text_box
function:
const activate_or_create_text_box = function(point){
for(let i=0, len=all_texts.length; i<len; i++){
if(all_texts[i].hit(point)){
all_texts[i].set_focus();
return;
}
}
all_texts.push(new TextBox({
x: point.canvas_x,
y: point.canvas_y,
canvas: canvas,
font_colour: font_colour,
font_size: font_size,
background_colour: background_colour
}));
};
This function will loop over our all_texts
array to determine if the Point
of interaction coincides with an
existing text input. If it does, we activate that existing text input. Otherwise we create a new TextBox
object and add that
to our all_texts
array. The TextBox
class is a custom class which we use to represent these editable text captions,
we will shortly examine how these TextBox
objects work, but first we introduce a couple of utilities that will be needed.
Utilities
With no libraries to lean on we will need to add a debounce
method. The implementation looks like this:
window.debounce = (callback, wait) => {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback.apply(null, args);
}, wait);
};
};
Wrapping the callback
in debounce
ensures that the callback
is only invoked
after a delay of wait
has elapsed without callback
having been triggered. This behaviour
is very useful when multiple events are fired rapidly in quick succession, but we only want to trigger the callback
when the events stop. In our case, when the user is typing into the text input we don't really want to react on each input, rather
we want to react once the user has finished. In this case, we interpret a long pause as indicating that the user has finished typing.
Another utility which we will require is the CanvasScaler
. As we saw previously, we have two sets of coordinates
that we need to map between: the (x,y)
viewport coordinates associated with our touch and mouse events, and the
(canvas_x, canvas_y)
coordinates that we use to animate on our canvas. The CanvasScaler
class
exposes methods which allow us to convert horizontal and vertical lengths in the viewport basis, over to the canvas basis, and
vice-versa.
class CanvasScaler {
constructor(canvas){
this.canvas = canvas;
this.css_width = window.getComputedStyle(canvas, null)
.getPropertyValue("width")
.replace(/px$/, '');
this.css_height = window.getComputedStyle(canvas, null)
.getPropertyValue("height")
.replace(/px$/, '');
}
scale_to_canvas_horizontal(length){
return length*this.canvas.width/this.css_width;
}
scale_to_canvas_vertical(length){
return length*this.canvas.height/this.css_height;
}
scale_from_canvas_horizontal(length){
return length*this.css_width/this.canvas.width;
}
scale_from_canvas_vertical(length){
return length*this.css_height/this.canvas.height;
}
};
With these utilities we can jump into examining the TextBox
class.
The TextBox
class
If you took a look at the video at the top of the post you would see the behaviour that we are aiming to implement here. When the text-input tool is activated we want to be able to click at a point on the canvas and create a text-input box, where we enter our text. We press return and the text is rendered in a box, if we re-click on the box we should be able to edit the text. Once the text box has been rendered we should be able to activate our selection tool box on the canvas.
Given that our text-box is a rectangle and that we want to be able to drag and reposition it on the canvas, it should
not be surprising that we we reuse one of existing Rectangle
classes, as introduced in
Part II to give us some of this functionality.
Our new TextBox
will extend the
DraggableRectangle
class:
class TextBox extends DraggableRectangle {
constructor({x, y, width, height, canvas, font_colour, font_size, background_colour}={}) {
super({x, y, width, height, canvas});
this.font_colour = font_colour;
this.font_size = font_size;
this.background_colour = background_colour;
this.scaler = new CanvasScaler(canvas);
this.text_box = null;
this.text = "";
SHAPE.add_rectangle(this.draw());
this.text_box = this._add_textbox();
this.input = this.text_box.querySelector("[contenteditable]");
this.padding = 0.25*Number(window.getComputedStyle(document.body).getPropertyValue('font-size').match(/\d+/)[0]);
}
When building an instance of TextBox
we must pass the usual parameters associated with our
Rectangle
class, namely, the (x,y)
viewport co-ordinates of the rectangle, dimensions for
the rectangle width
and height
and the canvas
element on which we want
to overlay the rectangle. These attributes are relayed to the base class via the call to super
. We also note here, that
within this constructor we will call the this.draw()
method on the TextBox
. This will return the
current instance, which we then pass over to the SHAPE
module via the add_rectangle
call.
We can see from the implementation in the SHAPE
module that this method will simly add our new
TextBox
instance to the all_rectangles
array:
s.add_rectangle = function(rect){
all_rectangles.push(rect);
};
By adding our DraggableRectangle
subclass to this array, we can leverage the select-to-drag functionality that
we implemented for our rectangles in Part II.
In addition to the properties relating to the drawing of the geometric shape, the TextBox
instance will
also store attributes relating to the text: font_colour
, font_size
and background_colour
.
And the actual text value, this.text
, will be initialized to a blank string.
The TextBox
will also maintain its own instance of the CanvasScaler
class we introduced in the previous
section; this scaler will be stored on the this.scaler
property for convenience.
on the TextBox
instance,
The last major function of this constructor is to setup the actual input element for collecting the user's text input.
This is achieved by the this._add_textbox
method, with a reference stored in this.text_box
property.
Before concluding, the constructor will cache a couple of values for convenient use later: this.input
holds a reference
to the actual contenteditable
input field, while this.padding
caches a calculated padding value for the
input element, based on the document font-size. Most of the interesting logic is captured in the _add_textbox
method ,
so we will focus on this method in the next section.
The _add_textbox
method
This method is called within the TextBox
constructor to create the actual text input that the user will interact with.
It will dynamically build a $wrapper
div containing a $input
element and a $close_btn
element. The $wrapper
element will be absolutely positioned on the page using the (x, y)
viewport
coordinates passed to the TextBox
constructor (which, in turn, come from the user's touchend
or
mouseup
interaction with the canvas).
The $input
element is set to be contenteditable
and we apply a series of styles to this element. Importantly we
set the width and height of the input element to match the rectangle dimensions, but this requires the use of our CanvasScaler
to convert the rectangle dimensions form the canvas-scale into the viewport-scale.
Finally we stitch together the different parts of the $wrapper
element, append it to the document and set browser focus on the
contenteditable
$input
element, awaiting the user's input.
_add_textbox(set_focus=true){
// Add an absolutely positioned text input
const $wrapper = document.createElement("div"),
$input = document.createElement("span"),
$close_btn = document.createElement("span");
$close_btn.classList.add("destroy");
$close_btn.innerText = "X";
$wrapper.classList.add("text-wrapper");
$wrapper.style.position = "absolute";
$wrapper.style.top = this.top_left().y+"px";
$wrapper.style.left = this.top_left().x+"px";
$input.setAttribute("contenteditable", true);
$input.style.position = "absolute";
$input.style.top = "0px";
$input.style.left = "0px";
$input.style.width = "auto";
$input.style.height = this.scaler.scale_from_canvas_vertical(this.height)+"px";
$input.style.minWidth = this.scaler.scale_from_canvas_horizontal(this.width)+"px";
$input.role = "textbox";
$input.style.color = this.font_colour;
$input.style.fontSize = this.font_size + "px";
$input.style.backgroundColor = this.background_colour;
$wrapper.appendChild($close_btn);
$wrapper.appendChild($input);
document.body.appendChild($wrapper);
if(set_focus){
$input.focus();
}
$input.addEventListener("input", debounce(this.draw.bind(this), 1000), false);
$input.addEventListener("input", this._resize_box.bind(this), false);
$close_btn.addEventListener("click", this.destroy.bind(this), false);
return $wrapper;
}
At the end of the _add_textbox
method you can see that we attach a number of listeners to handle the user input.
The click-handler on the $close_btn
will simply destroy this TextBox
and its associated DOM element.
We also add two listeners to the input
event on the $input
element. The draw
handler is debounced using the utility function we introduced earlier. This listener is intended to render the text caption when the user
has finished typing, which we infer from a pause of 1000ms.
The draw
function (shown below) initializes the canvas if this has not already happened. As a quick reminder, each Rectangle
instance creates a completely new canvas
element in the DOM which is overlayed over the original canvas and inherits some of its attributes.
With the new canvas set up the super.draw
is invoked, setting the fill parameter provided the background-colour has been set to any
non-transparent colour. This step will draw the background rectangle for our text caption, but the actual text is rendered by means of the
_draw_text
invocation. This function will write text to the canvas using
the native fillText
method, taking care to set the correct position, color and size for the text. In particular, determining the exact canvas position for the text
requires a little thought, as we want this text to precisely match the position of the content added to the contenteditable
$input
element. To calculate this we need to incorporate the top-left of the containing Rectangle
along with
the font-size and padding, as shown:
$input.addEventListener("input", debounce(this.draw.bind(this), 1000), false);
…
draw(opts={}){
this._init_canvas();
const orig_colour = this.ctx.fillStyle;
this.clear();
this.ctx.fillStyle = this.background_colour;
super.draw({fill: this.background_colour!=="transparent"});
this.ctx.fillStyle = orig_colour;
this._draw_text();
return this;
}
…
_draw_text(){
if(!this.text_box){
return;
}
const point = new Point({
x: this.top_left().x + this.padding,
y: this.top_left().y + this.font_size + this.padding,
canvas: this.canvas
});
this.with_fill_colour(this.font_colour, function(){
this.ctx.font = this.font_size + "px sans-serif";
this.ctx.fillText(this.input.textContent, point.canvas_x, point.canvas_y);
}.bind(this));
this.text_box.style.display = "none";
}
The draw
handler is only triggered after a pause in the user input. By contrast the _resize_box
handler will fire on each input
event:
$input.addEventListener("input", this._resize_box.bind(this), false);
…
_resize_box(event){
const old_width = this.width,
old_height = this.height;
this.width = this.scaler.scale_to_canvas_horizontal(this.input.clientWidth + 2*this.padding);
this.height = this.scaler.scale_to_canvas_vertical(this.input.clientHeight);
this.x = this.x + (this.width-old_width)/2;
this.y = this.y + (this.height-old_height)/2;
if(event.inputType==="insertText" && event.data===null){
this._draw_text(event);
}
}
This function will read the new clientWidth
and clientHeight
of our contenteditable
input element. It will then use the change in width and height to recalculate the position and width of our rectangle in canvas coordinates.
This ensures the bounding Rectangle
expands automatically to accommodate the text typed by the user.
Conveniently, in this regard, our CSS for the contenteditable
element ensures that it's width will resize appropriately to
accommodate the user input:
.text-wrapper [contenteditable] {
display: inline-block;
width: auto;
white-space: nowrap;
…
}
With these pieces stitched together we get the behaviour we were aiming for.
Summary
In this tutorial we have demonstrated how we can implement functionality which allows a user to add text captions to our drawing canvas.
This required that we introduce a new TextBox
class which extended the previously-implemented
DraggableRectangle
.
The new TextBox
had to extend the draw
method to, not only draw the rectangle on the canvas, but also draw
the user-entered text within the rectangle, using the native fillText
method. The TextBox
instance also needed
to manage an absolutely positioned contenteditable
element which was revealed or hidden depending upon whether the user
was actively inputting/editing the text caption.
So that's it, the last tutorial in this four-part series. I hope you found it useful. If you have been here from the start … Congratulations, I seriously didn't expect anyone to stick with the whole thing! I could barely motivate myself to write it :)
As always, if you have any questions or feedback please let me know in the comments section.
References
- You can access a hosted version of the drawing tool here
- The GitHub repo to accompany this series of blog posts
- The version of the tool built in this tutorial can be found on this branch
- The revealing module pattern for modular Javascript
- The MDN docs for
fillText
method.
Comments
There are no existing comments
Got your own view or feedback? Share it with us below …