How to Make Canvas Text Selectable?
TextInput controls are complicated
Let me begin by saying I am not an expert in text controls, but by now I'm sure that this doesn't matter, because I can help you get into the woods and out safely. These things are complicated in nature and require plenty of intuition and knowledge of how things work. However, you can inspect the code that runs in the senpai-js/senpai-stage
repository here.
We should define a few things up front:
- Text can be any valid unicode character. You can parse that using this regex:
/^.$/u
- You need to keep track of three different sorts of text editing modes:
Insert
,Selection
,Basic
(I use theSelectionState
enum in my library and inspect theinsertMode
property on stage) - You should implement sanity checks at every turn, or you will have undefined and unexpected behavior
- Most people expect text inputs to be sizable by width, so make sure you use a pattern for the innards of the text box if you plan on using a texture
- mouse/touch point collision detection is complicated unless you guarantee that the text input control won't rotate
- The text should scroll when it's larger than the textbox in the horizontal direction. We will refer to this as
textScroll
which is always a negative number
Now I will go over each function to describe it's behavior to describe exactly how a textbox control should work.
Collision (broadPhase, and narrowPhase)
Collision detection is a monster. Normalizing point movement between mouse and touch events is a complicated beast not covered in this text. Once you handle point events, you have to perform some sort of general collision detection for a rectangle. This means doing AABB collision. If the textbox sprite itself is rotated, you will have to "un-rotate" the point itself. However, we bypass this check if the mouse/touch point is already down over the textbox. This is because once you start selecting text, you want this function to always return true
. Then we move to narrowPhase collision, which actually checks to see if the "un-transformed" mouse/touch point is within the padding of the textbox. If it is, or the textbox is active, we return a truthy value here.
Once we know that the mouse/touch point is within the bounds of our textbox, we change the css of the canvas to cursor: text;
visually.
pointCollision
When we press the mouse button down over the textbox, we need to calculate where to move the caret. The caret can exist in a range from 0
to text.length
inclusive. Note that this isn't exactly right because unicode characters can have a length of 2
. You must keep track of each character added to your text inside an array to assert that you aren't measuring faulty unicode characters. Calculating the target index means looping over each character of the current text and appending it to a temporary string, measuring each time until the measured width is greater than the current textScroll + the measured textWidth.
Once we have garunteed that the point has gone down on top of the textbox and the starting point is set, we can start the "selection" mode. Dragging the point should move the selection from the starting caretIndex to the new calculated end index. This goes in both directions.
An example of this is shown here.
keyPresses
The solution for web key presses is to inspect the key
property on the KeyEvent. Despite a lot of what everyone says, it's possible to test that text property by testing it against the aforementioned unicode regex. If it matches, chances are that key has actually been pressed on the keyboard. This doesn't account for key combinations like ctrl + c
and ctrl + v
for copy and pasting. These features are trivial and are left up to the reader to decide how to implement these.
The few exceptions are the arrow keys: "ArrowLeft", "ArrowRight" etc. These keys actually modify the state of your control, and change how it functions. It's important to remember that key events should only be handled by the currently focused
control. This means you should check and make sure the control is focused during text input. This of course happens at a higher level than I have coded in my library, so this is trivial.
The next problem that needs to be solved is how each character input should modify the state of your control. The keyDown
method discerns the selectionState
and calls a different function based on it's state. This is not optimized pseudo-code, but is used for clarity, and is perfect for our purposes in describing the behavior.
keydown on a selection
- Normal key presses replace the content of the selected text
- Splice out from
selectionStart
, and insert the new key into the text array - if "delete" or "backspace" is pressed, splice out the selection and return the selection mode to
Normal
orCaret
- if the "left" or "right" key is pressed, move the cursor to the beginning or the end respectively and return the selection mode to Normal unless the shift key is pressed
- if the shift key is pressed, then we actually want to extend the selection further
- selection start will always be at the caretIndex, and we essentially move the selection end point left or right with this key combination
- if selection end returns back to the caret index, we return the
selectionState
toNormal
again
- the "home" and "end" keys work the same way, only the caret is moved to
0
andtext.length
indexes respectively- also note that holding down the shift key extends selection from the
caretIndex
once again
- also note that holding down the shift key extends selection from the
keydown on normal mode (caret mode)
- in caret mode, we don't replace any text, just insert new characters at the current position
- keydowns that match the unicode regex are inserted using the splice method
- move the caret to the right after splicing the text in (check and make sure that you don't go over text length)
- Backspace removes one character before the index at
caretIndex - 1
- Delete removes one character after the index at
caretIndex
- text selection applies for left and right keys while the shift key is pressed
- when shift isn't pressed, left and right move the caret to the left and right respectively
- the home key sets the caretIndex to
0
- the end key sets the caretIndex to
text.length
keyDown on insert mode
- in insert mode, we replace the currently selected character at
caretIndex
- keydowns that match the unicode regex are inserted using the splice method
- move the caret to the right after splicing the text in (check and make sure that you don't go over text length)
- the backspace remove the character BEFORE the current selection
- delete removes the currently selected character
- the arrow keys work as expected and described in normal mode
- the home and end keys work as expected and described in normal mode
updating the textbox every frame
- If the textbox is focused, you should start flashing the caret to let the user know they are editing text in the textbox
- when moving the caret left or right in
Caret
mode, you should restart the flash mechanism so that it shows exactly where they are each time the caret moves - Flash the caret about once every 30 frames, or half a second
- measure how far the caret is along the text by using
ctx.measureText
to the caret index by slicing the text to the caret position unless the mode isSelection
- It's still useful to measure how far along the text is in selection mode
Selection
, because we always want the end of the text selection to be visible to the user - Make sure that the caret is always visible within the visible bounds of the textbox, taking into account the current textScroll
rendering the textbox
- save the context first
ctx.save()
(basic canvas) - if you are not drawing the textbox with paths, draw the left cap of the textbox, draw the middle pattern, and the right cap respectively on the first layer
- use a path defined by the padding and the size of the textbox to clip out a square to prevent the text from bleeding out
- translate to the x
textScroll
value which should be a negative number - translate to the y
midline
value which should be the middle of the textbox vertically - set the font property
- set the text baseline to
middle
and fill the text by callingtext.join("")
on your text array - if there is a selection, or insert mode, make sure to draw a "blue" square behind the selected text and invert the font color of the selected text (this is non-trivial and left to the reader as an exercise)
Text selection has many components to it some visual, some non-visual.
First, make text selectable you must keep an array, of where the text is, what the text is, and what font was used. You will use this information with the Canvas function measureText.
By using measureText, with your text string, you can identify what letter the cursor should land on when you click on an image.
ctx.fillText("My String", 100, 100);
textWidth = ctx.measureText("My String").width;
You will still have to parse the font height from the "font" property, as it is not currently included in text metrics. Canvas text is aligned to the baseline by default.
With this information, you now have a bounding box, which you can check against. If the cursor is inside of the bounding box, you now have the unfortunate task of deducing which letter was intentionally selected; where the start of your cursor should be placed. This may involve calling measureText several times.
At that point you know where the cursor should go; you will need to store your text string as a text string, in a variable, of course.
Once you have defined the start and stop points of your range, you have to draw a selection indicator. This can be done in a new layer (a second canvas element), or by drawing a rectangle using the XOR composition mode. It can also be done by simply clearing and redrawing the text on top of a filled rectangle.
All told, text selection, text editing in Canvas are quite laborious to program, and it would be wise to re-use components already written, Bespin being an excellent example.
I'll edit my post should I come across other public examples. I believe that Bespin uses a grid-based selection method, possibly requiring a monospaced font. Ligatures, kerning, bi-directionality and other advanced features of font rendering require additional programming; it's a complex problem.