This is a technical article.
I am building a jQuery plugin to as areplacement for the venerable HTML TEXTAREA tag so I can do some fanciness.
In my textarea plugin I want to be able to insert blocks of HTML into a contenteditable DIV that should be treated as single objects. These can be wrapped in a SPAN or a DIV.
One of my requirements is that, I should be able to move the cursor over them. If Im to the left of the object and press the right arrow key, the cursor should jump to the end of the object and visa versa.
So my approach is to hook the onKeyDown event and check whether or not I am next to an object. In the handler I know which key on the keyboard was pressed by inspecting the event.which property. ( I hope to discuss the horrors of event handling in keyboard event handlers in another article.) So I know whether or not the LEFT or RIGHT arrow keys have been pressed.
But I also need to know WHERE the cursor is and I have to be able to select where it should go.
To get the current cursor position you have to use the DOM SELECTION and RANGE features, in modern browsers. MSIE added these features in IE9. Before that it used its own system.
Selections and ranges primarily deal with when you use the mouse (or SHIFT KEY and ARROWS) to select a block of content on a page. However, they are also used when you click the mouse at a given location or, in the case of a contenteditable block, where youve moved the cursor.
To get the current cursor position in the editable div you can use:
var sel = window.getSelection();var range = sel.getRangeAt(0);
The range object includes a bunch of properties. You have to remember that in a contenteditable div the underlying data structures are DOM nodes. Its important to note that there are different kinds of nodes present in a DOM (Document Object Model) structure. For my purposes, I am interested in two kinds of nodes: ELEMENT nodes and TEXTNODES.
ELEMENT nodes represent HTML tags. They can be containers that contain other tags. They may be <BR> tags representing a line break, which cannot contain other nodes. (foreshadowing) TEXTNODES represent text content, the written word.
TEXTNODEs are treated differently from ELEMENT nodes. Its important to know the jQuery seems to almost entirely ignore TEXTNODEs. The .from() and .next() methods in jQuery do not ever return TEXTNODEs.
So we want to get the cursor (a,k.a. caret) position. But we get a range object back. Since its a range, the range may start on one kind of node, say a text node at a given character offset, and then span a bunch of other nodes and end at some other textnode or similar at an offset.
So the range gives you the startContainer, which is the node where the range starts, the startOffset, which is the offset into that node (or character position if its a text node). The same occurs for the end of the range. There is also another property which is the deepest node in the tree that contains the entire selection.
The caret position, startContainer and endContainer will be the same as will be the case for startOffset and endOffset. There a convenience property collapsed that if true lets you know youre dealing with a single point select, i.e. the caret/cursor position.
But a single point range does not always give you a character offset in text.
Read on.
It seems simple enough. If I can move the cursor somewhere, I had assumed there would be a text node there that the cursor would occupy.
This assumption cost me quite a bit of time.
Consider the following block of HTML
<div id="div" contenteditable="true">this is some text<span id="span1">TEXT IN A SPAN</span><br><span id="span2">SOME OTHER TEXT IN ANOTHER SPAN</span></div>
And a little bit of jQuery/Javascript:
$( '#button' ).bind( 'click', function() { var sel = document.getSelection(); var range = sel.getRangeAt(0); alert( "container is '" + range.startContainer.nodeName + "'" ); });// now move the cursor AFTER the second span programmatically$( '#button2' ).bind( 'click', function() { var span_node = $( '#span2' ).get(0); var sel = document.getSelection(); var range = document.createRange(); range.setStartAfter( span_node ); range.collapse( true ); sel.removeAllRanges(); sel.addRange( range ); var new_range = document.getSelection().getRangeAt(0); alert( "New container is '" + range.startContainer.nodeName + "' with offset '" + range.startOffset + "' childNodes.length '" + range.startContainer.childNodes.length ); // for the benefit of FireFox $( '#div' ).focus(); });
You can see this test here:http://jsfiddle.net/7cYff/5/
If you click on the world ANOTHER then move the cursor to the right as far as you can with the arrow keys and click the first button, which will query the range and print out the name of the node that the caret is currently, we see that we get a TEXTNODE. Ok, that makes sense.
The second button in the example, uses the RANGE selection method setStartAfter() to move the current selection AFTER the span. If you press this button you will notice that we get a startContainer of the parent DIV with an offset of 4.
This confused me for quite some time as I didnt understand what this was supposed to mean.
Each DOM node has a property called childrenwhich I incorrectly believed contained all the children of that node. That array did not have 4 entries in it. It turns out, that
The children DOM Node property does not, in fact, contain the children of a node
It only contains the child ELEMENTS of a node. The childNodes property contains all children of a node including textnodes and the like.
If you inspect the childNodes property, you will notice it only has 3 entries, not 4. So we have gotten an offset OUTSIDE of the range of our childNodes.
I should point out that the select addRange() method has no return code and when you select a node that cant be selected you get no exception:https://developer.mozilla.org/en-US/docs/DOM/Selection/addRange
Now for the icing on the cake, try typing something into the jsFiddle after pressing the second button and right click -> Inspect Element.
The text you just entered was put INSIDE the SPAN.
In Chrome and Safari, you cannot enter anything after the end if theres an element there. So imagine its a thumbnail or an @mention. You do the @mention and start typing, youll be typing INSIDE the @mention span.
Now repeat the experiment using FIreFox. It seems to behave more or less the same way EXCEPT the contenteditable DIV loses focus which is why I call the .focus() method in the example above.
Now repeat the experiment. Press the second button, start typing and then inspect it using FireBug (youll have to install the FireBug extension). You will notice the text you just entered is AFTER the span.Same code, different behavior.
In FireFox, if you call range.setStartAfter() a container and start typing the text will be entered after the container.
What about the reverse? Lets say we have the following:
<p>Test1:</p><div id="test1" contenteditable="true">The cursor will jump here -><br><span id="span1" contenteditable="false">NOT EDITABLE</span> [click here] Move cursor left using left arrow key. If you are in Chrome of Safari you will not be able to move the cursor to the beginning of this line.</div>
See it here:http://jsfiddle.net/YyTEP/
You can mark elements inside a contenteditable as not editable by adding contenteditable=false. In Chrome, this is nice because itll automatically jump the cursor over the item for you. No having to worry about checking to see if youre inside. It also prevents the user from clicking inside the range of the element. It would have been so nice if this actually worked. (Foreshadowing.)
In Chrome, click the mouse on the [click here] in the first block then use the left cursor to move over the NOT EDITABLE span. To my shock and horror, the cursor will move to the LINE ABOVE! If you move it to the right, itll jump back to the place before the NOT EDITABLE.
If you do the same thing in FireFox, you get the same result using the left arrow key. But if you then use the RIGHT arrow key, Chrome jumps back to the end of the SPAN but Firefox moves the cursor in front of the span.
(One meme, so appropriate for a wide range of situations.)
It becomes more complicated, if you have something like the following:
<div id="test" contenteditable="true"><div id="div1">FIRST DIV</div><div id="div2">SECOND DIV</div><div>
And you attempt, using code similar to the previous example, to select the node after DIV id=test1?. You can see the example here:http://jsfiddle.net/jdmDj/4/
In Chrome, if you click the button, you get the main DIV as a parent and a startOffset of 1, which is the second div. If you start typing, as in the previous example, the content you enter will go into the div, not between it.
In FireFox, however, the same experiment will cause the content to be inserted between the DIVs and a new line will open up.
For completeness sake, what happens if you try to select the space before the first div?
Using the same markup above and changing the setStartAfter() to setStartBefore(), can we select the spot BEFORE the id=div1? div? See the example here:http://jsfiddle.net/jdmDj/5/
In this situation we get a 0 for startOffset which represents the id=div1? div. We asked for the node in front of the first DIV and we got that DIV back without any error. This works the same in Chrome as in Firefox however as has been the case with the examples above, in Chrome the text you enter in this location will get entered into the beginning of the first DIV while the content entered in FireFox will cause a new line to be opened up in FRONT of the first DIV.
In summary:
Using WebKit browers (Chrome and Safari), you cannot select the locations before the first element, after the last element or between any two elements. Any text you enter will be entered into the beginning or end of one of the adjacent elements depending on circumstance.
What does this mean?, the business guys will always ask. It means that if you insert something at the beginning of a line theres no way to get in front of it with the cursor to insert something before it. If its at the end of a line theres no way to get behind it to continue typing after it.
This sucks.
So I considered adding an element before and after each thing I might want to insert. This sucks because its going to muck up the way the content looks. I thought about adding a textnode with a space in it before and after each element I insert. This would allow me at least to select it in Chrome/Safari, but it would be ugly because the users would get confused why this extra space was getting added.
So I posted a question on StackOverflow:
Interestingly, I got two relatively notable developers to respond. Tim Down who wrote theRangy cross browser range selection library. He clearly understands these range related subjects far better than I do. He said:
Browsers are inconsistent on this. Firefox will let you position the caret in more positions than most browsers but WebKit and IE both have definite ideas about valid caret positions and will amend a range you add to the selection to conform to the nearest valid position. This does make sense: having different document positions and hence behaviours for the same visual caret location is confusing for the user. However, this comes at the cost of inflexibility for the developer.
This is not documented anywhere. Thecurrent Selection specsays nothing about it, principally because no spec existed when browsers implemented their selection APIs and there is no uniform behaviour for the current spec to document.
I also received a response from Reinmar who describes himself as aCKEditor core developer & JavaScript ninja. CKEditor is one of the premier HTML WYSIWYG editors out there and is all about manipulating content in contenteditable areas. They have had such trouble with this very issue that theyve implemented a completely different solution to get in front of things which involves hovering a mouse over the area and clicking on a little bar to open up a newline. Ive played with it and it seems to me to place more of a burden on the user than at least my users are willing to accept.
But it was something that Tim Down said that gave me an idea.
,,, create elements with, say, a zero-width space character for the caret to be placed in and place the caret in one those elements when necessary. As you say, ugly.
Heres a case of being too old school. I grew up in ASCII, and to a much lesser degree EBCIDIC. ASCII does not have an invisible placeholder character that one might use to create an apparently empty text node.
However, UNICODE has a zero width space character, code \u200B.
Immediately a path opened up. It would be challenging because of all the edge cases, but if I could wrap any of my elements in zero width space characters as they are being inserted I would have a way of selecting the space before and after the element regardless of whether or not it was:
The downside to this approach is that its a bit ugly. If you go interspersing zero width space characters everywhere and the user wants to use an arrow key or a backspace over them, they will each consume a keystroke without moving the cursor. You can see this in this fiddle:http://jsfiddle.net/BxapL/
Just click on the [click here] and use the arrow keys to move left. Youll press the arrow key at some point and the cursor will not move.
In order to not confuse the users, for all key presses, arrow key moves, and searching whether we are right next to an object or not (for highlighting purposes) any zero width space characters will need to be skipped over so that users does not know they are there.
This means managing every keystroke in onKeyDown and in some cases onKeyUp for all arrow keys. But it also means being careful to catch an ENTER key and fixing up anything on the previous line. Chrome wraps each line of text in a <div> </div>. Internet Explorer uses <p> </p> and FireFox does the worst of the bunch by adding <br _moz_dirty="> for a new line.
Chrome also adds:
<div><br></div>
whenever the ENTER key is pressed to open up a new line. It then unceremoniously deletes the BR as soon as the user enters some text . sometimes. Other times itll leave <BR>s lying around inside the DIVs but I have not yet been able to identify any particular pattern to it.
So, this is the approach I have mostly implemented. And it works, mostly. Unfortunately, it is still relatively easy to get the DOM nodes behind the scenes to be out of whack.
The above algorithm would have worked so well and solved so many of my issues if it werent for some really nasty browser bugs Ive run into.
Unfortunately, setting contenteditable=false to my inserted objects breaks Chrome and Safari. After all that work and getting it to work what I thought was flawlessly, I inserted an object in the middle of a line and tried to merge the line with the one above it by pressing BACKSPACE at the beginning of the line. To my shock, the embedded object and the remaining text on the line disappeared.
You can see this in action here.http://jsfiddle.net/pmARP/
So this means I cant use the contenteditable=false trick to take care of making sure the cursor jumps over the embedded objects automatically. I have to check each time the cursor moves, an object is inserted, the mouse is used that the cursor hasnt made it into the confines of an embedded object and then I need to move the cursor out of it. Because of all the range challenges described above and the fact that for some reason my zero width space characters are getting deleted, this has proven to be the core of my difficulty.
I could just try to educate my users to press the right arrow key a couple of times and that would work around it, but that would spell doom for my little social network. It has to work and it has to work well.
I wanted to put a border around my objects when the cursor is close to them or when theyre clicked on. But I also wanted to be able to display my objects inline. An @mention isnt much good if you have to put it on a separate line.
If you use a SPAN tag, you cant set a border because its an inline element.
If you use a DIV tag, you can specify a border but its a block level element and opens up a new line.
Enter the new display: CSS property inline-block that gives you the best of both worlds. This was great. I liked being able to do the outlines. But then I tried to press the UP and DOWN arrow. I spent two days trying to figure out what in my code was causing the up and down arrow not to work. Eventually I posted this question to StackOverflow:
It turns out to be a bug in WebKit browsers. So Ive had to opt to using SPANs for my embedded objects. No outlines since you cant put an outline on a SPAN.
The autocomplete trigger code is working pretty well. However, I discovered that in FireFox the UP and DOWN arrows would not work in a contenteditable div much in the same way that they didnt in Chrome. Initially, once again, I thought it was my own bug. Another day lost.
It turns out its some detail about how FireFox implements keypress events. There is a bug filed in the jQuery bug tracker about it. In the mean time, Ive hacked jQuery UI to work around it at least for the time being.
After working on this stuff for this long, I felt the need to explain to myself the problems Ive been running into and verify that I really did understand what I was seeing and was able to verify it. Spending to day writing this has highlighted a few things I did misunderstand which may explain some of the remaining problems I have in the implementation.
Ill spend tomorrow trying to update the code, which is now a few thousands lines long, to reflect these understandings to see if that improves things.
If I can get this thing to work it really will be a good little widget, but man I tell you this has been more of a pain in the ass. This article represents only a subset of the problems Ive run into during the development of this jQuery plugin.
If you know someone who fighting the same good fight, maybe you could pass along the article to them. Finding people who know about these issues has proven to be quite challenging.
You must be a member of this group to post comments.
Please see the top of the page to join.