Categories
Software Development Web Development

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.

Categories
Web Development

Javascript event listeners be careful where you bind this

In Javascript User interface programming you often need to work with EventListeners. In modern Javascript you may also be doing this in a class, which is handy. One of the  things that sucks about Javascript event listeners is they eat memory and slow your app down, if you are not REALLY careful.

sweet meme
Slow down your app the easy way!

They do this because each time the action you use to add the event listener code fires, more and more code gets added to memory, more things need to be kept up with etc.

There are some best practices that help with this issue such as always remembering to remove the event listeners, never use anonymous functions, arrow functions etc. Here on MDN there is a lot of good information I won’t waste time repeating in this article.

What I want to cover in this article is properly binding this within a class when using Event Listeners so that the function signatures match and therefore the listeners can properly be removed. Don’t think reloading the page solves your problem either… it doesn’t. In fact it just makes the browser eat more and more memory.

The reason we need to bind this is the code in our Javascript classes that handle the events ( event handlers ) usually need to access other methods and properties within the class when called by the event listener code.

Magical Bugs

Worse than the memory issue is the magical, insanely, nearly impossible bugs that having multiple listeners calling the same function can create.

If for example the function changes a variable value so that some UI action changes etc. One example I just ran into was a method in a class that monitored a keypress then moved the cursor. This was for an auto completer with a drop list of suggestions. The idea was to have Javascript highlight the selected option and accept it when the user hits enter. Below is the code example.

keyPressHandler(event) {

        let target = event.target.id;
        this.keyPressed = event.keyCode;
        this.optionElementsList = document.getElementById(target + "-ez-hashtags-list");

        if (this.optionElementsList) {
            this.optionElementsList = this.optionElementsList.getElementsByTagName("div");
        }

        switch (event.keyCode) {
            /*If the arrow DOWN key is pressed,
           increase the currentChoice variable:*/
            case 40:
                console.log('inside keyPressHandler handling keydown event');
                this.currentChoice++;
                this.makeActive();
                break;
            /*If the arrow UP key is pressed,
       decrease the currentFocus variable:*/
            case 38 :
                console.log('inside keyPressHandler handling keydown event');
                this.currentChoice--;
                this.makeActive();
                break;
            /*If the ENTER key is pressed, prevent the form from being submitted,*/
            case 13 :
                event.preventDefault();

                if (this.currentChoice > -1) {
                    /*and simulate a click on the "active" item:*/
                    if (this.optionElementsList) {
                        this.optionElementsList[this.currentChoice].click();
                    }
                    //empty the keysTyped variable
                    this.keysTyped = '';
                }
                break;
            case 8 :
            case 9 :
                //9 is tab, close any list if they hit tab
                //8 is backspace close the current list if they hit backspace
                this.closeOpenLists();
                break;
        }
    }

This is similar to what you need to do when creating a custom WYSIWYG html editor. You need to monitor every damn key pressed and perform actions based on that. The real fun begins when you have two listeners say one for keyup and one for keydown or just two of the same.

The code above if the user hits the up or down arrow decreases or increases the variable used to track the users expected action. It then calls another method to make the correct option highlighted for the user as selected. Below is the code.

makeActive() {

        if (!this.optionElementsList) {
            return false;
        }
        /*start by removing the "active" class on all items:*/
        this.makeInactive();

        if (this.currentChoice >= this.optionElementsList.length) {
            this.currentChoice = 0;
        }
        if (this.currentChoice < 0) {
            this.currentChoice = (this.optionElementsList.length - 1);
        }
        /*add class "autocomplete-active":*/
        this.optionElementsList[this.currentChoice].classList.add("ez-hashtags-active");
    }

This is where the currentChoice variable comes in play. The up and down arrows add the class which changes the background color to make it look highlighted. Then when the user hits enter, the selected value is entered into the input, but in this case a div with contenteditable=true This UI stuff is a real pain in the ass.

The BAD CODE

Now lets look at the wrong way to bind this, even though it looks like this should totally work properly.

 createChoiceList(elementId, inputValue) {

        if (Utils.isEmpty(inputValue)) {
            return false;
        }
        this.currentChoice = -1;

        if (!Utils.isEmpty(this.listOptions)) {

            if (Utils.notEmpty(this.optionContainer)) {
                this.closeOpenLists();
            }

            /*create a DIV element that will contain the items (values):*/
            this.optionContainer = document.createElement("DIV");
            this.optionContainer.setAttribute("id", elementId + "-ez-hashtags-list");
            this.optionContainer.setAttribute("class", "ez-hashtags-items");
            /*append the DIV element as a child of the hashtag div container:*/
            this.hashtagsDivElement.parentNode.appendChild(this.optionContainer);

            /*for each item in the array...*/
            for (let i = 0; i < this.listOptions.length; i++) {

                /*check if the item starts with the same letters as the text field value:*/
                let firstLetter = this.listOptions[i].substr(0, inputValue.length).toUpperCase();

                if (firstLetter === inputValue.toUpperCase()) {

                    /*create a DIV element for each matching element:*/
                    let b = document.createElement("DIV");
                    /*make the matching letters bold:*/
                    b.innerHTML = "#" + this.listOptions[i].substr(0, elementId.length) + "";
                    b.innerHTML += this.listOptions[i].substr(elementId.length);
                    /*insert an input field that will hold the current array item's value:*/
                    b.innerHTML += "";
                    /*execute a function when someone clicks on the item value (DIV element):*/
                    this.optionContainer.appendChild(b);
                }
            }
            this.hashtagsDivElement.addEventListener('keydown', this.keyPressHandler.bind(this));
            this.optionContainer.addEventListener('click', this.choiceSelectionHandler.bind(this));
        }
    }

The offending code is the last two lines that add the even listeners. The intention here was to make the signature match the remove event listener code. Like  this below.

removeKeypressHandlers() {
        try {

            if (this.hashtagsDivElement) {
                this.hashtagsDivElement.removeEventListener('keydown',this.keyPressHandler.bind(this));
            }

            if (this.optionContainer) {
                this.optionContainer.removeEventListener('click', this.choiceSelectionHandler.bind(this));
            }

        } catch (e) {
            console.log("OOPS we caught an error " + e);
        }
    }

It looks good, like they match like it should function, right?

makes sense to me meme
Looks good to me…

WRONG

Even though those functions look like they match, bind(this) creates a new function every time it is called.

scared chimp
Oh that can’t be good. That can’t be good at all.

So now we have LOTS OF EVENT LISTENERS. And that causes magical bugs like the video below.  The real good example is after the 30 second mark.

Notice how when I first type the selection works. Then when I backspace and start again it just jumps to the last one. Then I backspace and start again and it works. Then it doesn’t I was like WTFF I am losing my mind. Watch towards the end for the magic to start really good.

God forbid you ever meet an articulate ass.

articulate ass
Meet the articulate ass

The right way

So how should we defeat this Javascript demon and bind this properly for our event handlers??? Well the answer is quite simple actually. You create another new variable in the constructor and bind this to the handler there. This way bind creates only 1 function, 1 time and it can easily be removed. As a bonus if you add the function 100 times in 100 calls, the browser will only actually add it 1 time.

So lets see the code.

        constructor(inputElementId, endpointUrl, fetchOnce = true, keysThreshold = 2) {

        //set the routes for the Routing object
        Routing.setRoutingData(routes);
        this.inputElementId = inputElementId;
        this.currentChoice = -1;
        this.endpointUrl = endpointUrl;
        this.fetchOnce = fetchOnce;
        this.inputElement = document.getElementById(inputElementId);
        this.hashtagsDivElement = null;
        this.hashtagValues = '';
        this.inputValues = '';
        this.optionContainer = null;
        this.optionElementsList = null;
        this.listOptions = '';
        this.keyPressed = '';
        this.keysTyped = '';
        this.boundChoiceSelectionHandler = this.choiceSelectionHandler.bind(this);
        this.boundKeypressHandler = this.keyPressHandler.bind(this);
        this.boundTypingHandler = this.handleTyping.bind(this);
        //add the hashtags div and hide the actual input element
        this.hideInputElement();
        this.addHashtagsDiv();
    }

As you can see in the code above,this goes in the class constructor. Anywhere else and you could be creating the same listeners over and over.

Then to actually add the listeners when you want them to be added you do this little trick below.

this.inputElement.addEventListener('keydown', this.boundKeypressHandler);
            this.optionContainer.addEventListener('click', this.boundSelectionHandler);

Notice now the binding is done in the constructor and the variable that holds the bound function is added in place of the previous binding in the addEventListener() calls. This way these eventListeners are only ever created once, the other times are ignored. Javascript bind actually creates and returns a new function with the scope needed to use this keyword.

To remove the event listeners later to cleanup memory, you do this.

removeKeypressHandlers() {
        try {

            if (this.inputElement) {
                console.log('Removing the input element keydown handler');
                this.inputElement.removeEventListener('keydown', this.boundKeypressHandler);
            }

            if (this.optionContainer) {
                console.log('removing the option container click handler');
                this.optionContainer.removeEventListener('click', this.boundSelectionHandler);
            }

        } catch (e) {
            console.log("OOPS we caught an error " + e);
        }

    }

Notice now the signatures of the calls match exactly this.boundxxx

I had learned this information many years ago and forgot it until I had this hard to catch bug above. I then finally remembered I was was doing stupid things and how to do this properly.

These kind of things will make you hate Javascript UI programming or any DOM action programming. I hope I never forget this lesson again.

Categories
Uncategorized

How to update your FosJsRoutingBundle routes file

When you add a new route to one of your Symfony API routes aka your controllers, you will need to recreate the JSON routing file that FosJsRoutingBundle uses.

To do that you use this simple command.

php bin/console fos:js-routing:dump --format=json --target=public/js/fos_js_routes.json

This command will rewrite your existing routes JSON file located in the public folder of your project.

Then to make sure your routes are working you display them with this command.

php bin/console fos:js-routing:debug

This will output your currently available routes created with the line above.
To learn more about the command read this short article. How to view Symfony 5+ FosJsRoutingBundle routes

To learn how to get the Javascript routes in your javascript code in the first place with FosJsRoutingBundle read this book I wrote on the subject. LOL How to get URL Routes in your Javascript in Symfony 5+

Categories
Resources Web Development

How to view Symfony 5+ FosJsRoutingBundle routes

I can never remember this command. However, now that I am creating more AJAX and exposing endpoints I need this command more often.

This command is found in the docs too. But I can never remember where, so I will write about it here.
The command to view how FosJsRoutingBundle views your routes.

php bin/console fos:js-routing:debug

This will list something like this for you.

 Name                  | Method | Scheme | Host | Path                   |
+-----------------------+--------+--------+------+------------------------+
| menu                  | GET    | ANY    | ANY  | /menu                  |
| alias_exists          | ANY    | ANY    | ANY  | /alias_exists          |
| email_exists          | ANY    | ANY    | ANY  | /email_exists          |
| get_image_collections | GET    | ANY    | ANY  | /get_image_collections |
| save_user_image       | POST   | ANY    | ANY  | /save_user_image       |
| get_user_image_data   | GET    | ANY    | ANY  | /get_user_image_data   |
+-----------------------+--------+--------+------+------------------------+

Isn’t that pretty.This is not dynamically updated. You need to re-output your routes to the file FosJSRoutingBundle expects them to be in, so that when it creates a route it matches. I need to write an article about how to do only that. For now scroll to the bottom of the following article. How to get URL Routes in your Javascript in Symfony 5+

But if you don’t update your routes they won’t match the above output and your routes will fail when you use FosJS. Mine seemed to just redirect to my current page. Your setup/configs may differ.

Categories
Resources Web Development

How to view Symfony routes

I often need to view my current routes and how the Symfony kernel views them. This usually happens when I am adding new routes, I end up getting conflicts and have to resolve them by viewing what exists etc.

The command to view your routes is really simple. Open your console and navigate to your projects main directory and type the following command to see a list of all of your current Controllers routes.

php bin/console debug:router

That will output a list of all of your routes. Something like this

user_settings                 GET        ANY      ANY    /user-settings                       
  profile_unavailable           GET        ANY      ANY    /profile-unavailable{page_data}      
  save_user_settings            POST       ANY      ANY    /save-user-settings                  
  _preview_error                ANY        ANY      ANY    /_error/{code}.{_format}             
  _wdt                          ANY        ANY      ANY    /_wdt/{token}                        
  _profiler_home                ANY        ANY      ANY    /_profiler/                          
  _profiler_search              ANY        ANY      ANY    /_profiler/search                    
  _profiler_search_bar          ANY        ANY      ANY    /_profiler/search_bar                
  _profiler_phpinfo             ANY        ANY      ANY    /_profiler/phpinfo                   
  _profiler_search_results      ANY        ANY      ANY    /_profiler/{token}/search/results    
  _profiler_open_file           ANY        ANY      ANY    /_profiler/open                      
  _profiler                     ANY        ANY      ANY    /_profiler/{token}                   
  _profiler_router              ANY        ANY      ANY    /_profiler/{token}/router            
  _profiler_exception           ANY        ANY      ANY    /_profiler/{token}/exception         
  _profiler_exception_css       ANY        ANY      ANY    /_profiler/{token}/exception.css     
  show_dash                     ANY        ANY      ANY    /admin                               
  tos                           GET        ANY      ANY    /app/pages/tos                       
  privacy                       GET        ANY      ANY    /app/pages/privacy                   
  block_user                    POST       ANY      ANY    /block-user                          
  unblock_user                  POST       ANY      ANY    /unblock-user                        
  blocked_users                 GET        ANY      ANY    /blocked-users                       

As you can see there are 5 columns. The first column is the routes name, tos for example. Here is how tos is declared in the Controller

* @Route("/app/pages/tos", name="tos", methods={"GET"})

The second column is what type of HTTP request is allowed POST, GET, HEAD etc. The next two columns are the Scheme and Host, dig in the docs under routing, I believe, if you want more info on those two. The final fifth column is the Path, this is what your URL should look like.

Categories
Software Development Web Development

Symfony how to get query string values sent by AJAX calls

When using a Symfony API endpoint for getting values such as maybe something like an auto-complete feature you will need to be able to send some text(what the user enters) to the backend. Then have the backend return a response based on that.

For example with an auto complete feature, you might want to send 3 characters to an API endpoint and have it only return a list of words that start with those three characters, instead of returning every last word in the database. This will use less of your servers resources and the users while speeding your app up.

The way I suggest to do this is to first use FosJsRouting bundle in your Javascript to create the URL’s for you. This makes it much easier, faster ( Once you get it installed etc ) and consistent. Here is a long article I wrote about installing and using FosJsRouting bundle How to get URL Routes in your Javascript in Symfony 5+.

This is a short example and doesn’t include checking if the user is logged in ( has rights) if it is an AJAX request, check headers etc. it is just for this example.



/**
     * returns a list of the users image collection names
     * @Route("/get_image_collections", name="get_image_collections", options={"expose"=true},  methods={"GET"})
     */
    public function get_image_collections(Request $request): JsonResponse
    {
        $query = $request->getQueryString();
      $text2 = $request->query->get('text');
      $text =  $request->get("text");
        $values = array(
            'first',
            'second',
            'third',
            'fan',
            'free',
            'narcotics',
            'arse',
            'tardigrade',
            'tinnitus',
            'monkey',
            'mall',
            'doppleganger',
            'ballocks',
            'zoo',
            $query,
            $text,
            $text2
        );
        return new JsonResponse($values);
    }

This is very simple, it doesn’t even hit a database, it is just for this article and testing. All this does right now is return the list and add some text and query so I can see what was sent. In production you might even want to use caching so that your database only gets hit if it has to.

As you can see there appears to be two ways to get the value of the query. Both worked for me, but I am thinking that going through the query method is the proper way like follows.

$request->query->get('text');

The query being used for this endpoint looks like this

"http://sogi-test/get_image_collections?text=fa"

In the future I’ll update the code to include security checks, to return a matching list compiled from a database query and caching.

But notice the (Request $request) that is Dependency Injected ( see symfony dependency injection )

I should also add, below is how I am building the URL with FosJsBundle

let url = Routing.generate('get_image_collections', {text: text});

That is where the “text” named query key is coming from. You can name the key anything you want. If you have more than one named value then use a comma and add it after the first, like in the FosJsRoutingBundle docs under how to use above link.
For example say you want to include something like a page number for paging through results or limiting them etc. You could create the
url route like this

let url = Routing.generate('get_image_collections', {text: text, limit: limit});

This Javascript JSON looks confusing, the first part is the name(key) the second is a(value) variable containing the value
Then to get the values in your controller route you do this.


$text = $request->query->get('text');
$limit = $request->query->get('limit');

You can send as many values as you need to this way. Forms work a little differently. I’ll write an article about those soon too.

And that is how you get the values you send.
baby how it is done meme
That is how it is done

Interesting Error story

I figured I would mention this debugging story I had while sending an ajax request with an improperly formed URL ( aka not matching the route)

I kept getting errors when trying to use Promise.json() with the value returned from my AJAX call. What happened is the server was returning HTML instead of JSON which results in an error when Promise.json() is called.

A while of that and thinking it was my JS code but not really sure of WTF was going on. I finally started outputting my URL to see what was being created and low and behold that was it. I was even watching the Request/Response in the browser. But, it was not obvious until I really started inspecting the created URL vs the route output that I figured out my route and url didn’t match and that was the reason for the redirect.

How to view Symfony routes

 

My route didn’t match because at some point I changed how I wanted the route to look and I had not rerun the FosJsRouting bundle as mentioned in that article to renew the routes in it’s json file.  So it created a URL that didn’t match, the route was created like /route/{text} but the api route expected /route&text=….

I didn’t get an error. Nope and nothing was recorded that I could find in the error logs. Symfony simply redirected ( 301) sent back to my ajax which then somehow did another request to the page I was using the javascript in,  returning the pages HTML.

I was like

Uhm, wait… what?

I have no idea why Symfony redirects when the route doesn’t match.  I don’t even know what setting to adjust where to change this, I know I did something somewhere at sometime… I’ll have to dig into what I did.

Why this happend is because I didn’t re-update the FosJsRouting json file and then restart webpack so it pulls in the new file. But I know there is some sort of setting somewhere for Symfony for the redirect action I am seeing and I know I set it somewhere somehow.

Can I have hamburger now???