Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Todd Rafferty
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Todd Rafferty@webRat )

Enable Tabbing Within A Fenced Code-Block Inside A Markdown Textarea In JavaScript

By Ben Nadel on

Last week, I took a look at using the Flexmark Java library to parse markdown content into HTML output in ColdFusion. Shortly thereafter, I enabled markdown formatting in my blog comments. This was a huge leap forward for usability. But, it still left something to be desired. While the markdown parser allows for fenced code-blocks, the Textarea input doesn't inherently have code-friendly keyboard functionality. As such, I wanted to see if I could enable code-oriented keyboard combinations like Tab, Shift+Tab, Enter, and Shift+Enter while the user was entering a comment; but, only if the user were currently editing text that was wholly contained within a fenced code-block.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

This isn't the first time that I've looked at enabling / overriding the Tab keyboard event while using a Textarea. Two years ago (almost exactly), I experimented with a [tabEnabled] attribute directive in Angular 2 Beta 17 that would apply tabbing functionality to any Textarea host element. The difference with the current exploration is that it's not in an Angular application; and, it doesn't affect the entire Textarea - only the moments in which the user is editing a fenced code-block.

In markdown, a fenced code-block is content that is surrounded by tripple back-ticks. As the user enters text into the textarea, I can determine if they are currently editing a fenced code-block if the following conditions are true:

  • The current selection does not contained the tripple back-tick.
  • There are an odd number of tripple back-tick instances prior to the selection start.

The first condition ensures that the user's selection doesn't overlap with a fenced code-block boundary. The second condition tells me that the user is inside a fenced code-block that has not yet been closed. As such, these conditions let me know when I should be observing and (potentially) overriding keyboard events like Tab and Shift+Tab.

To see this in action, I've put together a demo in which the user is presented with a Textarea. This Textarea will listen for and override various meaningful keyboard combinations while the user is editing a fenced code-block:

  • <!doctype html>
  • <html lang="en">
  • <head>
  • <meta charset="utf-8" />
  • <title>
  • Enable Tabbing Within A Fenced Code-Block Inside A Markdown Textarea In JavaScript
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css" />
  • </head>
  • <body>
  •  
  • <h1>
  • Enable Tabbing Within A Fenced Code-Block Inside A Markdown Textarea In JavaScript
  • </h1>
  •  
  • <textarea name="comment" tabindex="1"></textarea>
  • <button type="buttom" tabindex="2">Submit Comment</button>
  •  
  • <script type="text/javascript">
  •  
  • // Gather our DOM references.
  • var input = document.querySelector( "textarea[ name = 'comment' ]" );
  •  
  • // Figure out which whitespace characters we need to use when adjusting our
  • // textarea value.
  • var newline = getLineDelimiter();
  • var tabber = "\t"; // You could also use spaces here, if that's your thing.
  •  
  • input.addEventListener(
  • "keydown",
  • function( event ) {
  •  
  • if ( isRelevantKeyEvent( event ) && isSelectionInCodeBlock( input ) ) {
  •  
  • console.warn( "Overriding Key Event:", event.key, ( event.shiftKey ? "+ SHIFT" : "" ) );
  •  
  • applyKeyEventToCodeBlock( input, event );
  •  
  • // Since we're programmatically applying the key to the contextual
  • // code-block, we don't want the default browser behavior to take
  • // place - we are going to manually apply the key to the input state.
  • event.preventDefault();
  •  
  • }
  •  
  • },
  • false
  • );
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  • // I apply the given keyboard event to the given input.
  • // --
  • // NOTE: At this point, the calling context has already determined that the event
  • // is relevant and needs to be applied.
  • function applyKeyEventToCodeBlock( input, event ) {
  •  
  • var state = getInputState( input );
  •  
  • // Each method here takes the input state and return a new one. We can then
  • // take the new state and re-apply it to the input.
  • if ( isTabEvent( event ) ) {
  •  
  • state = insertTabAtSelection( state );
  •  
  • } else if ( isShiftTabEvent( event ) ) {
  •  
  • state = insertShiftTabAtSelection( state );
  •  
  • } else if ( isEnterEvent( event ) ) {
  •  
  • state = insertEnterAtSelection( state );
  •  
  • } else if ( isShiftEnterEvent( event ) ) {
  •  
  • state = insertShiftEnterAtSelection( state );
  •  
  • }
  •  
  • setInputState( input, state );
  •  
  • }
  •  
  •  
  • // I find the index of the end-line that contains the given offset.
  • function findEndOfLine( value, offset ) {
  •  
  • return( value.indexOf( newline, offset ) );
  •  
  • }
  •  
  •  
  • // I find the index of the line-start that contains the given offset.
  • function findStartOfLine( value, offset ) {
  •  
  • var delimiter = /[\r\n]/i;
  •  
  • // Starting at the current offset, let's start walking backwards through the
  • // value until we either run out of characters; or, we hit a character that
  • // represents some line delimiter.
  • for ( var i = ( offset - 1 ) ; i >= 0 ; i-- ) {
  •  
  • if ( delimiter.test( value.charAt( i ) ) ) {
  •  
  • return( i + 1 );
  •  
  • }
  •  
  • }
  •  
  • return( 0 );
  •  
  • }
  •  
  •  
  • // I get the current selection state of the given input.
  • function getInputState( input ) {
  •  
  • return({
  • value: input.value,
  • start: input.selectionStart,
  • end: input.selectionEnd
  • });
  •  
  • }
  •  
  •  
  • // I calculate and return the newline implementation for the current browser.
  • // Different operating systems and browsers implement a "newline" with different
  • // character combinations.
  • function getLineDelimiter() {
  •  
  • var fragment = document.createElement( "textarea" );
  • fragment.value = "\r\n";
  •  
  • return( fragment.value );
  •  
  • }
  •  
  •  
  • // I apply an ENTER key change to the given input state. This will add a newline
  • // at the given selection point, starting the subsequent line at the same
  • // indentation as the preceding line.
  • function insertEnterAtSelection( state ) {
  •  
  • var value = state.value;
  • var start = state.start;
  •  
  • var leadingTabs = value
  • .slice( findStartOfLine( value, start ), start )
  • .match( new RegExp( ( "^(?:" + tabber + ")+" ), "i" ) )
  • ;
  •  
  • var tabCount = leadingTabs
  • ? ( leadingTabs[ 0 ].length / tabber.length )
  • : 0
  • ;
  •  
  • var preDelta = value.slice( 0, start );
  • var postDelta = value.slice( start );
  • var delta = ( newline + repeat( tabber, tabCount ) );
  •  
  • return({
  • value: ( preDelta + delta + postDelta ),
  • start: ( start + delta.length ),
  • end: ( start + delta.length )
  • });
  •  
  • }
  •  
  •  
  • // I apply a TAB key change to the given input state. This will increase the
  • // indentation of the lines contained within the given selection.
  • function insertTabAtSelection( state ) {
  •  
  • var value = state.value;
  • var start = state.start;
  • var end = state.end;
  •  
  • // If the selection has length zero, then we're simply inserting a tab
  • // character at the current location. However, if the selection crosses
  • // multiple characters, then we're going to adjust the indentation of
  • // the lines affected by the selection.
  • var deltaStart = ( start === end )
  • ? start
  • : findStartOfLine( value, start )
  • ;
  • var deltaEnd = end;
  • var deltaValue = value.slice( deltaStart, deltaEnd );
  •  
  • var preDelta = value.slice( 0, deltaStart );
  • var postDelta = value.slice( deltaEnd );
  •  
  • // Insert a tabber at the start of the delta, plus any contained newline.
  • var replacement = deltaValue.replace( /^/gm, tabber );
  •  
  • var newValue = ( preDelta + replacement + postDelta );
  • var newStart = ( start + tabber.length );
  • var newEnd = ( end + ( replacement.length - deltaValue.length ) );
  •  
  • return({
  • value: newValue,
  • start: newStart,
  • end: newEnd
  • });
  •  
  • }
  •  
  •  
  • // I apply a SHIFT+ENTER key change to the given input state. This will add a
  • // newline after the line of the current selection start, starting the new line
  • // as the same indentation as the preceding line.
  • function insertShiftEnterAtSelection( state ) {
  •  
  • var value = state.value;
  • var start = state.start;
  •  
  • var leadingTabs = value
  • .slice( findStartOfLine( value, start ), start )
  • .match( new RegExp( ( "^(?:" + tabber + ")+" ), "i" ) )
  • ;
  •  
  • var tabCount = leadingTabs
  • ? ( leadingTabs[ 0 ].length / tabber.length )
  • : 0
  • ;
  •  
  • var deltaStart = findEndOfLine( value, start );
  • var preDelta = value.slice( 0, deltaStart );
  • var postDelta = value.slice( deltaStart );
  • var delta = ( newline + repeat( tabber, tabCount ) );
  •  
  • return({
  • value: ( preDelta + delta + postDelta ),
  • start: ( deltaStart + delta.length ),
  • end: ( deltaStart + delta.length )
  • });
  •  
  • }
  •  
  •  
  • // I apply a SHIFT+TAB key change to the given input state. This will decrease
  • // the indentation of the lines contained within the given selection.
  • function insertShiftTabAtSelection( state ) {
  •  
  • var value = state.value;
  • var start = state.start;
  • var end = state.end;
  •  
  • var deltaStart = findStartOfLine( value, start )
  • var deltaEnd = end;
  • var deltaValue = value.slice( deltaStart, deltaEnd );
  • var deltaHasLeadingTab = ( deltaValue.indexOf( tabber ) === 0 );
  •  
  • var preDelta = value.slice( 0, deltaStart );
  • var postDelta = value.slice( deltaEnd );
  •  
  • var replacement = deltaValue.replace( new RegExp( ( "^" + tabber ), "gm" ), "" );
  •  
  • var newValue = ( preDelta + replacement + postDelta );
  • var newStart = deltaHasLeadingTab
  • ? ( start - tabber.length )
  • : start
  • ;
  • var newEnd = ( end - ( deltaValue.length - replacement.length ) );
  •  
  • return({
  • value: newValue,
  • start: newStart,
  • end: newEnd
  • });
  •  
  • }
  •  
  •  
  • // I determine if the given keyboard event represents the ENTER key.
  • function isEnterEvent( event ) {
  •  
  • return( ( event.key.toLowerCase() === "enter" ) && ! event.shiftKey );
  •  
  • }
  •  
  •  
  • // I determine if the given value is odd.
  • function isOdd( value ) {
  •  
  • return( ( value % 2 ) === 1 );
  •  
  • }
  •  
  •  
  • // I determine if the given keyboard event is one of the events that we might
  • // want to manage for use in a fenced code-block.
  • function isRelevantKeyEvent( event ) {
  •  
  • return(
  • isTabEvent( event ) ||
  • isShiftTabEvent( event ) ||
  • isEnterEvent( event ) ||
  • isShiftEnterEvent( event )
  • );
  •  
  • }
  •  
  •  
  • // I determine if the given input has a selection that is entirely contained
  • // within a fenced code-block. This can be a multi-character selection or a
  • // simple caret selection.
  • function isSelectionInCodeBlock( input ) {
  •  
  • var state = getInputState( input );
  • var selectedValue = state.value.slice( state.start, state.end );
  •  
  • // If there is a code-fence contained within the selection, then the
  • // selection crosses a code-block boundary and we don't want to mess with it.
  • if ( selectedValue.indexOf( "```" ) >= 0 ) {
  •  
  • return( false );
  •  
  • }
  •  
  • // Get the number of code-fences that come before the start of the selection.
  • var preSelectedValue = state.value.slice( 0, state.start );
  • var codeFences = preSelectedValue.match( /```/g );
  •  
  • // If there are no fences before the selection then we know that we're not in
  • // the middle of a fenced code-block.
  • if ( codeFences === null ) {
  •  
  • return( false );
  •  
  • }
  •  
  • // If there are fences before the selection, an odd number will indicate that
  • // we're inside a fenced code-block that has not yet been closed.
  • return( isOdd( codeFences.length ) );
  •  
  • }
  •  
  •  
  • // I determine if the given keyboard event represents the SHIFT+ENTER key.
  • function isShiftEnterEvent( event ) {
  •  
  • return( ( event.key.toLowerCase() === "enter" ) && event.shiftKey );
  •  
  • }
  •  
  •  
  • // I determine if the given keyboard event represents the SHIFT+TAB key.
  • function isShiftTabEvent( event ) {
  •  
  • return( ( event.key.toLowerCase() === "tab" ) && event.shiftKey );
  •  
  • }
  •  
  •  
  • // I determine if the given keyboard event represents the TAB key (ensuring that
  • // the SHIFT key is NOT also depressed).
  • function isTabEvent( event ) {
  •  
  • return( ( event.key.toLowerCase() === "tab" ) && ! event.shiftKey );
  •  
  • }
  •  
  •  
  • // I repeat the given string the given number of times.
  • function repeat( value, count ) {
  •  
  • return( new Array( count + 1 ).join( value ) );
  •  
  • }
  •  
  •  
  • // I apply the given selection state to the given input.
  • function setInputState( input, state ) {
  •  
  • // If the value hasn't actually changed, just return out. There's no need to
  • // alter the selection settings if nothing changed in the value.
  • if ( input.value === state.value ) {
  •  
  • return( false );
  •  
  • }
  •  
  • input.value = state.value;
  • input.selectionStart = state.start;
  • input.selectionEnd = state.end;
  •  
  • return( true );
  •  
  • }
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, I'm listening for all keydown events on the Textarea. But, I don't actually override any of the functionality unless the following expressions are True:

isRelevantKeyEvent( event ) && isSelectionInCodeBlock( input )

These two methods tell me that the user is editing code in a fenced code-block. And, that the given keyboard event is one that has special meaning. Now, if we open this up in the browser and try to enter text that contains a fenced code-block, you will see that tabbing is enabled. This is easier to see in the video, but here is the page output that we get:


 
 
 

 
 Enabling tab-based indentation in a fenced code-block within a greater Textarea. 
 
 
 

As you can see, we were able to easily add tabbing / indentation to the fenced code-block portion of the Textarea value. And, we did this in such a way that allowed the native browser behavior - like tabbing to the next input field - to work as long as the cursor was outside of a fenced code-block.

Hopefully, I'll get this added to my blog comment form shortly. I'm already loving the fact that I can use markdown in the comments. And, this kind of feature will really be a huge usability boost for editing code-samples on the fly.



Looking For A New Job?

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

I just added this functionality to the comments. Written here using tabs:

element.addEventListener(
	"click",
	function() {
		alert( "woot, you done clicked it good!" );
	},
	false
);

Nice. I also added CMD+Enter for submit action.

Reply to this Comment

Cool
I really like that demonstration of advanced usage of the select api. But actually the interesting part are the functions that calculate the tabs and insert them in the right place.

Reply to this Comment

@Zlati,

Ha ha, thanks -- I'm glad you enjoyed it. And, I agree -- dealing with the tabs and line-returns is by far the most interesting part of this demo.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.