Into Each Life a Little JS Must Fall
by joe

Posted on 2019-02-11



Oh, man! This is going to be fun. We just spent the last hour and a half getting the basics down. Now it will take several days to write a full blog post on the process.


Keyboard Shortcuts in the Browser

BLOX development is about to take a biggest-pain-in-the-ass detour into JavaScript. (See our "Write it for yourself" post here.)

We're getting tired of the awkward parade of "<" and ">" characters in drafting these blog post entries. Typing the extended pattern of "a" tags for external links, <a href="" target="_blank">, has become wearisome. Scrambling through the previous Rhyme entries for a brace of <br/> tags to cut and paste interrupts the flow of our creative juices. So we are going to write a bit - and only just a bit - of JavaScript coding to handle the most common or more difficult HTML tags. We'll capture a few Alt-something keys and assign each one to a specific tag, or partial tag, or set of tags (and maybe a few other useful characters), inserting them into the text or wrapping selected text as needed.

First Step: Capture Keyboard Input

It's not exactly luck, nor is it brilliance - we did a web search and found some example code on Stackoverflow immediately. (See "Some Resources..." sidebar.) The code was clean, clear and worked on the first try.


window.addEventListener('keydown', function(e) {
  if (e.altKey == true && e.keyCode == 78)
    console.log('Alt + N'); 
});

Whether it is StackOverflow or a blog post or official docs or a book - or occasionally some of our own old code - we start almost every task by looking up something. It's doubtful that we have ever sat down and pounded in the beginning set of files to start a new Flask application by just typing them into an editor. Almost everything new begins with a cut-and-paste seed crystal, counting on the super-saturated experience in our noggin to assemble the extended structure of code that ultimately crystalizes into a functional application. So it is with this cuppa project**.

We inserted this bit of JavaScript into the dbx/templates/base.html template to test it out, then fired up our browser to the edit_rhymes page and called up the Developer Tools page in the browser (Alt-Cmd-I on the Mac). When we pressed the Alt-N, which was actually Alt-Shift-N because Alt-n did not return keycode 78, the browser console obligingly displayed "Alt-N" on its console. It's hard to explain, but we always are a little surprised when something like this works on the first try. We get a warm, satisfying feeling of being on the right track.

Narrator: Actually, this foreshadows other trouble down the line.

**Our git branch for this project is named "cuppa". (We watch a lot of British cop shows.)

Second Step: Repurpose a Keyboard Input

Now that we can confidently capture a specific keyboard combination, we want to use it to insert specific HTML tags (and perhaps other characters) at the cursor position in the textarea. This process turns out to be much more involved and goes a bit more deeply into the DOM object, surfacing a surprise or two in the process.

We need to perform several explicit operations on the textarea object and some of its attributes. We need to:

  • suppress the output of the captured key combination
  • save the cursor location when the keypress occurred
  • insert the characters we want to go into the text
  • reposition the cursor using old position and insertion length
Let's walk through what implementations of each of these sub-steps.

Suppress output of the captured key combination
There are quite a few options for suppressing the normal output of a keypress. The one we selected to start with, "e.preventDefault();", is shown in the BLOX commit 80fffa1, which is reproduced below. Another way is to "return false;" from the window.addEventListener() function. Yet another option is "e.stopPropagation()". (See "Suppressing Keypress Output" and "Some Resources" sidebars.)

At this point we are not sure that e.preventDefault() is the final answer, but we are going to run with it for awhile.

Save the cursor location
Here's the fun part that took us the most time to get straight: the cursor position. As you can see from the intermediate code below, we were pounding out various values to the console log in order to find out what was what. The intermediate code shows both some of the false trail and realization of our intent.

The false trail indicator is the variable txt. It is the string value of the textarea element. We spent a bit of time scratching our heads as the PyCharm JavaScript editor kept telling us that the "selectionStart" attribute was not a modifier for the text string value in the textarea element. As shown in the intermediate code, "selectionStart" is an attribute of the textarea itself (e.target).

Intermediate Code

<script>
window.addEventListener('keydown', function(e) {
  if (e.altKey == true && e.keyCode == 78) {
      //console.log('Alt + N');
      let txt = e.target.valueOf().value;
      console.log(txt);
      let cPos = e.target.valueOf().selectionStart;
      insertAtCursor(e.target, '<br/><br/>\n\n');
      e.preventDefault();  // works for Brave - others???
      console.log('cPos=' + cPos);
      e.target.valueOf().selectionStart = cPos + 12;
      e.target.valueOf().selectionEnd = cPos + 12;
      console.log('New POS==' + e.target.valueOf().selectionEnd)
  }
});

function insertAtCursor(myField, myValue) {
    //IE support
    //if (document.selection) {
    //    myField.focus();
    //    let sel = document.selection.createRange();
    //    sel.text = myValue;
    //}
    //MOZILLA and others
    //else
    if (myField.selectionStart || myField.selectionStart == '0') {
        let startPos = myField.selectionStart;
        let endPos = myField.selectionEnd;
        myField.value = myField.value.substring(0, startPos)
            + myValue
            + myField.value.substring(endPos, myField.value.length);
    } else {
        myField.value += myValue + '\b';
    }
}

function putCursorThere(myField, cPos) {
    myField.selectionStart = cPos;
}
</script>

Insert the characters
We're still working with the intermediate code shown above, specifically at the function insertAtCursor().

We commented out the "IE Support" section simply because we do all of our development and testing on a Mac or Linux machine, and we want to focus on our environment where we can test the code. We'll do IE and Edge testing much later. Maybe.

Our own first test case was just to insert two break tags and two carriage returns at the end of a paragraph. The break tags provide paragraph breaks in the blog page display, and the carriage returns provide paragraph breaks within the textarea as we compose the blog post. The intermediate code works for this limited proof of concept operation.

But the general case for controlling the cursor while inserting text is a bit more elaborate. The selectionStart attribute has a complementary selectionEnd attribute. The "selection" part of the name indicates that we have access to the cursor position that precedes a block of selected text along with the cursor position that follows the selected text. This is a bonus we never expected - JavaScript is presenting all the information we need to insert both opening tags and closing tags to modify the intervening text.

Going in to this exercise, we expected to be able to insert opening tags with an Alt-something key combination in order to ease our formatting burden. But we also expected to continue typing in the closing tag by hand as we have from the beginning. Being presented with everything we need to facilitate inserting properly matched tags around selected text feels like a gift.

That's not in the intermediate code, however. All we are doing in the insertAtCursor() function is inserting the two break tags and the two linefeeds at the cursor position (selectionStart and selectionEnd are the same when no text is selected), whether in mid-string or at the end.

Reposition the cursor
Following the call to the insertAtCursor() function, the default position of the textarea cursor is at the end of the string value attribute. Again, our original plan was to insert an opening tag somewhere in the middle of the string value and to preserve the cursor position at the same relative position in the string, i.e., at the end of the inserted text and immediately in front of the same character as before. For the single test case in this proof of concept implementation, the intermediate code works just fine.

It's time to clean things up and take advantage of the bounty we have been offered.

Implementation Plan

We want to implement a half-dozen or so inserts that are commonly used in composing these blog posts: anchor tags, italics, bold, paragraph breaks, image tags, and perhaps a few others. Looking over the operation of the intermediate code, we see the following items to address:

  • make a data structure keyed by the "x" part of Alt-x
  • text inserts will have opening and closing text strings
  • simplify reconstruction of the textarea string value
  • to reset the cursor we use length.opening_tag


Data Structure
The tags dictionary in the "First Implementation" code (BLOX commit 11e6243) shown below fulfills our first two planned elements. The elements are keyed by the numerical value of the keyCode for the key pressed while the Alt key is held down. Insertions are structured to handle the general case for placing opening tags before selected text and for placing closing tags at the end of the selected text. For insertions that are not actually paired HTML tags, the 'close' value is an empty string.

A couple of extras
The tags data structure contains one entry we had not planned - the 'h' entry for a Help dialog popup. This is useful because we were unable to use the most natural keys for many of the insertion choices.

For example, we could not use either 'e' (for <em>) or 'i' (for italic) because Alt-e and Alt-i returned the value 229 instead of the expected values of 69 and 73, respectively.

Atl-Shift-E and Alt-Shift-I return 69 and 73 as expected, but we decided to stick with non-intuitive lowercase letters instead of going to a three-key implementation.

This decision for handling oddball key triggers then led to the need for a Help dialog popup.

We're using a relatively new Apple wireless extended-key keyboard, and the Mac stalls out whenever we attempt to "Change Keyboard Type" under the Keyboard option from the System Preferences app. Therefore it is entirely possible that the keys we cannot get to work would work for you.

First Implementation***

<script>

let tags = { // 'a'
            65: {'open': '<a href="" target="_blank">',
                 'close': '</a>'},
            // 'b'
            66: {'open': '<br/>',
                 'close': ''},
            // 'h'    ---->  alert help screen
            72: {'open': '',
                 'close': ''},
            // 'l'
            76: {'open': '<u>',
                 'close': '</u>'},
            // 'm'
            77: {'open': '<img src="" width="100" height="100" />',
                 'close': ''},
            // 'p'
            80: {'open': '<br/><br/>\n\n',
                 'close': ''},
            // 's'
            83: {'open': '<strong>',
                 'close': '</strong>'},
            // 't'
            84: {'open': '<em>',
                 'close': '</em>'}
           };

let debug = true;

window.addEventListener('keydown', function(e) {
  if (e.altKey !== true || e.target.tagName !== 'TEXTAREA') {
      return;
  }
  if (debug) {
      console.log('keyCode==' + e.keyCode);
      console.log(e.target.tagName);
  }
  if (!(e.keyCode in tags)) {
      if (debug) {
          console.log('KeyCode not in tags: ' + e.keyCode)
      }
      return;
  }
  if (e.keyCode == 72) {
      alert('a = Anchor\n' +
            'b = Break\n' +
            'h = Help (this screen)\n' +
            'l = underLine\n' +
            'm = iMage\n' +
            'p = Paragraph\n' +
            's = Strong\n' +
            't = em (iTalic)\n'
           );
      return;
  }

  let cPosStart = e.target.valueOf().selectionStart;
  let cPosEnd = e.target.valueOf().selectionEnd;
  insertAtCursor(e.target, tags[e.keyCode]);
  e.preventDefault();  // works for Brave - others???
  e.target.valueOf().selectionStart = cPosStart + tags[e.keyCode]['open'].length;
  e.target.valueOf().selectionEnd = cPosEnd + tags[e.keyCode]['open'].length;
  if (debug) {
      console.log('cPosStart=' + cPosStart + '    cPosEnd=' + cPosEnd);
  }

});

function insertAtCursor(myField, myValue) {
    //IE support
    //if (document.selection) {
    //    myField.focus();
    //    let sel = document.selection.createRange();
    //    sel.text = myValue;
    //}
    //MOZILLA and others
    //else
    if (!myField.value) {
        return;
    }
    let startPos = myField.selectionStart;
    let endPos = myField.selectionEnd;
    let selection = myField.value.substring(startPos, endPos);
    let right_side = myValue['close'] + myField.value.substring(endPos, );
    let left_side = myField.value.substring(0, startPos) + myValue['open'];
    myField.valueOf().value = left_side + selection + right_side;
    if (debug) {
        console.log('start==' + startPos + '  end==' + endPos);
        console.log('selection==*' + selection + '*');
        console.log('leftside==*' + left_side + '*');
        console.log('rightside==*' + right_side + '*');
        console.log('myField==*' + myField + '*');
    }
}
</script>
    
    
    
Reset the cursor w/ length.tags[e.keyCode]['open']
This worked out much better than originally planned. After the call to insertAtCursor(), we now reset the selectionStart and selectionEnd values using the length of the appropriate tags[e.keyCode]['open'] value - thereby preserving the text selection. This is a real win because it delivers some nice functionality that, based on the example code we started with, we thought was unavailable. (See "Stacking or Chaining Tags" sidebar.)

***What we show above as the "First Implementation" code is actually the third commit of this code following the "Intermediate Code" that we used for proof of concept. This is not a real confession, because all of our commits are showing anyway.

Simplify reconstruction of the textarea string value
Looking at the "Intermediate Code", we don't quite understand the first IF test on the myField.selectionStart value in the insertAtCursor() function. It looks like it is meant to protect against an 'undefined' value or some such. When it encounters an unexpected value, it just tacks the myValue text onto the end of the original string. It's a mixed-message kind of protection. We have taken a different approach.

We've put in a protection in the window.addEventListener() function by requiring that event processing proceeds only when the browser is focused on a textarea field. That means we can expect a valid cursor position value for both selectionStart and selectionEnd. So we have no need to test the myField.selectionStart field. We simply build three elements and then concatenate them. The values for left_side, selection, and right_side seem clear, as does the reconstruction of myField.valueOf().value from those three strings.

This illustrates some of our philosophy for writing software. The only possible obscurity is the substring argument pattern (endPos, ). But we think it is clear enough that it captures the substring from the endPos position to the end of the string.

In Conclusion

The purpose of this post is to demonstrate how we go about coding in a secondary (for us) language such as JavaScript.

Over the years we have written commercial software (meaning we were paid for the results) in over 30 different programming languages on many combinations of operating systems, file systems, databases, etc.

In this exercise, we were able to do a little research, overcome some uncertainties by testing - and even improve on the exemplar code we use as the basis of our implementation. Along the way we adapted to the difficulties and exploited unexpected opportunities.

This is the essence of computer programming. And, as we expected, it was a lot of fun.

Curse of the Generalist
We have written a bit of JS over the years, but only in unavoidable spasms. Not our favorite language.

Every time we sit down to write a JavaScript utility we have to research some of the specifics required for the task. This exercise is no exception.
Some Resources (not necessarily in order)
The initial code to capture and react to an Alt-N keypress (first answer), from StackOverflow, of course.

NOTE: Another interesting StackOverflow Question/Answer here (fifth answer) for "Modern JS" also has a link to some worthwhile Mozilla docs.

For the preventDefault() code, see several answers, also on StackOverflow.

For inserting substitute text at the cursor position when the keypress occurs, we built off of these older StackOverflow answers.
Asking a Favor
We had a devil of a time figuring out just how to use the function insertAtCursor(). We initially thought that the parameter myField was the string value in the textarea element. In fact, though, the correct myField parameter is the textarea itself.

Our request to those of our reader who also post answers on StackOverflow or in their own blogs or elsewhere is: Along with the example function, show a realistic call of that function.

Our further request of all of our readers is: If you encounter an example on these pages where we have failed to do for you as we have asked of others, please let us know.
Suppressing Keypress Output
We found multiple mechanisms available in JavaScript for suppressing output of the Alt-something keys we repurposed for editing.

You can find a good explanation of the alternatives here. Be sure to read all of the answers.

We chose the least expansive suppression mechanism so that any problems with the implementation are more likely to be exposed.
IDE Misdirection
The first test in the window.addEventListener() function checks two conditions: 1) that we are holding down the Alt key; and 2) that the cursor is in a textarea.

Coming up with the second part of that test was quite a trial. We'll spare you the details, but even though the code is working now, PyCharm still does not indicate that tagName is a legitimate attribute of e.target.

Our reliance on code completion in PyCharm temporarily squelched a line of inquiry that ultimately proved successful. We are NOT complaining about PyCharm. Instead, we are suggesting that it is a mistake to rely on operational sugar like code completion to the exclusion of other sources of information.
"Stacking" or "Chaining" Tags
One side effect of the way we have implemented the insertion of begin-end tags is that we are able to "stack" or "chain" keypress insertions. That means that we can hold down the Alt key then press "t" and "s" (or vice versa) in sequence and get bolded and italicized text - like this. We can even triple up with an s-t-l sequence - like this.

We got there by modifying the code that we found to preserve both the selectionStart and selectionEnd values and then compute both offsets. It turns out that resetting both of those values preserves the text selection.
Unfortunate Limitation
The editing Undo operation (Cmd-Z on our Mac) does not work on the inserted text. We have not researched whether there is a way to get the inserted text into the edit buffer.
Can't Please Everyone
We really have no idea what it takes to accommodate the full range of browsers (brands and each version of each brand). We do almost all of our development on a Mac, with a bit of backend-mostly implementation on Linux systems as well.

BLOX is not a fully-vetted, broad-spectrum, browser-agnostic application.

While composing this post, we encountered multiple opportunities to improve on the "Initial Implementation" you see here. We also have found a few inconsistencies among the four browsers we run on our Mac. One simple example: Alt-h triggers the Help screen popup. Some browsers exit the popup when we type Esc, some do not. Some browsers preserve the cursor position and text selection and maintain the focus on the textarea; some do not.

Keeping all of that straight - and providing instructions for all contingencies in all browsers - is more than too much for us. We hope you understand.
Errata
The last statement in the "Intermediate Code" ends with '\b' - our first attempt to suppress the keypress output by backspacing over it. That mechanism did not work.


Comments

It will be some time yet before we get a comments section working here. In the meantime feel free to send comments via email. On this site our name is Joe Python. The email address is our first name at joepython.com.

Edited: 2019-02-13 21:28:57(utc) Generated: 2019-06-10 17:29:58(utc)