Javascript and contenteditable how to move the cursor to the end of user input

Javascript and contenteditable how to move the cursor to the end of user input

This appears to work in all browsers. Let me know if you find issues.

So this is something I needed to do and all the examples I found didn’t work properly. Or they worked partially, or only in one browser.

The idea seems super simple, but in reality the problem becomes complex for several reasons. For one, you need to have event listeners to listen for input then perform the proper actions to update the content and move the cursor to the end.

My use case makes it even harder and maybe yours does too. What I needed was to allow a user to enter hashtags and then send an ajax request to the backend to fetch a list of matching hashtags to display in a drop list below the input(auto suggestions). Then when the user selects one, add it to the list by removing the typing first, then add the new hashtag and move the cursor to the end.

At first I was all confident like

This should be easy right?

So that sounded easy. I got most of it working by using Google, reading some articles, reading stack questions… but it wasn’t working properly. My cursor would appear in random places or not appear, or not be where I wanted it to be. I tried reading the MDN docs for range and well I didn’t quite grasp how the system works.

If you are reading this you probably have done all this too and may be having issues as well.

Trying to get this working

One stupid, nonsensical issue I ran into right away was when the user hit backspace instead of one letter or hashtag being removed, ALL CONTENT WAS REMOVED.

homer simpson meme
Not the action we expected now is it.

I fixed this by adding   which is html for non breaking space. Explained in depth in this great article. Some people add an empty paragraph from what I read. I decided to use nbsp; because it is seen as a #text node not a text element like p for example.

So now when the user backspaces, the first backspace does nothing, but the next removes the hashtag. This repeats until there are no hashtags. I was using divs, with spans inside them. I moved to just a simple span element with @nbsp; added to the end.

My original html minus class and id’s

My first idea was to have a hashtag with an x to remove it. But then I was like, most people will just backspace and if they are on their phone no way they can accurately touch it.

I also discovered that you must add contenteditable=false to each element within the parent element so that when the user clicks, the cursor doesn’t appear inside some randomish point instead of at the end of the content.

So I adjusted it to be just the span with a following nbsp tag like this.

Ok so now I have these hashtags displaying correctly. I don’t like that I have to add the invisible space tag to make the contenteditable act right, but what can we do?

If you are wondering how I make these cool code images, you need to checkout this carbon app.

Below is the actual div and some sample hashtags I pulled from the developers console.

notice the html structure here.

See that autocomplete=off . Yeah you need that so that when a user types into input or contenteditable divs it doesn’t drop it’s own suggestion list over your suggestion list, it really makes things a mess.

Moving the cursor to the end.

In the video below you can see what I want and what I have gotten working so far. This pretty much works exactly how I wanted it to work. Notice the user can enter a hashtag at the beginning and when they choose enter the cursor moves to the end.

If you are working on a similar project and need the cursor to go to the end like this, then you know what a pain it was to get this figured out. LOL

I eventually got tired of the try, fail, google, curse, try, fail, curse some more routine. So I decided to dig directly into the W3C standards myself. It started making sense after  I read it a few times and then checked the MDN links above again and played around etc. I tried reading the DOM spec but I didn’t find it as helpful. But overall it took reading all of everything I could find and trial and error to figure it out.

Ok so what magical code have I used? I wont cover the entire class and how it works. I’ll just show the actual method that creates a range and moves to the end.

moveCursorAfterHashtags() {
        let hashtagRange = document.createRange();
        let windowSelection = window.getSelection();
        //remove any previously created ranges
        windowSelection.removeAllRanges();
        let theNodes = this.hashtagsDivElement.childNodes;
        this.hashtagsDivElement.focus();
        let firstNode = theNodes[0];
        let lastNode = theNodes[theNodes.length - 1];
        let start = theNodes[0];
        let end = theNodes[theNodes.length - 1];
        console.log('Start is ' + start.nodeName + ' end is ' + end.nodeName + " node count " + theNodes.length);
        hashtagRange.setStartBefore(firstNode);
        hashtagRange.setEndAfter(lastNode);
        hashtagRange.collapse(false);
       //add the range to a window selection object.
        windowSelection.addRange(hashtagRange);
        windowSelection.collapseToEnd();
        console.log('commonAncestorContainer contains ' + hashtagRange.commonAncestorContainer.id);
        console.log('The start container is ' + hashtagRange.startContainer.id);
        console.log('The end container is ' + hashtagRange.endContainer.id);
    }

Notice all the console logs needed to figure this out. LOL

So the code above creates a range. Then creates a selection object, this is needed to actually move the cursor to the end of the range with this line

windowSelection.collapseToEnd();

But before you move the cursor to the end you must collapse the range to a single point hence the line

 hashtagRange.collapse(false);

You will see people using setStart() and setEnd(). After reading the docs I found it MUCH EASIER. to just use setStartBefore() and setEndAfter().

This is because of the structure I am using a span followed by a plain #text node, which may or may not exist, and it makes calculating the offsets harder. This way if the user backspaces one time and the nbsp is removed, but the leading hashtag remains the range is still inclusive and less calculations needed.

so much thinking…

You can see I feed them a node. The first and last node are calculated with

let firstNode = theNodes[0];
let lastNode = theNodes[theNodes.length - 1];

hashtagRange.setStartBefore(firstNode); 
hashtagRange.setEndAfter(lastNode);

This is because .childNodes returns an array which starts with 0, so to get the last one you must reduce the count by 1. A 2 item array will have the values 0 and 1, because the numbering starts at 0.

In this terminology node is anything html element or just typed text or the nbsp; that makes the space. These are all called nodes.

The other start and end are old code just used for console logging to see WTF is going on LOL

Also notice where I set the focus to the parent div element before creating the range etc.

this.hashtagsDivElement.focus();

I am not sure if this makes a difference, I prefer to add focus to the element first.
And that is how you get the cursor to set at the end of the contenteditable div.

 

boom how it is done baby meme
Trial and error until it works

 

If this doesn’t work properly in all browsers it is the browser makes fault for not deciphering the cryptic standards better.

,

Need Help with your project, devops, debugging, wordpress issues? Contact Me! I’ll help you.

5 responses to “Javascript and contenteditable how to move the cursor to the end of user input”

    • Thanks. Nothing I could find would work so I had to dig into rfc’s to figure out how the hell any of it was supposed to work. It was driving me insane, the cursor would be anywhere but where I wanted it.

  1. this is super helpful thank you so much!!! every stackoverflow post i encountered was confusing AF and didn’t work at all… yours worked the way I want it…. i am using this for my textbox for chat app

    i did find something cool though… basically if you wrap the span[contenteditable=false] to a span with the end having a no width space, the cursor is less finicky

    @mentions&#8203

    when you delete no width space it will also delete the @mentions

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: