Skip to main content
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Seth Bienek
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Seth Bienek ( @sethbienek )

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

By 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.

Want to use code from this post? Check out the license.

Reader Comments

15,640 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.

8 Comments

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.

15,640 Comments

@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.

1 Comments

This is incredible stuff! Very useful for a project I've been working on that requires editing markdown in the browser.

The only issue I have noticed is that you cannot Undo/Redo a TAB or ENTER event (e.g. pressing ctrl-z after inserting a TAB does nothing). This makes sense as the browser likely does not have a notion for what Undo/Redo means in these instances.

My question for you: do you think it is possible to build upon your implementation to enable Undo/Redo for TAB, SHIFT+TAB, ENTER, and SHIFT+ENTER? My thought is to append something like

...
isUndo( event ) ||
isRedo( event )

to isRelevantKeyEvent(), but after that I'm getting stuck on a couple points:

  • You would have to know what the most recent event was (a standard keystroke which the browser can handle fine, or TAB/ENTER which you would need to add some logic to handle properly), which I'm not sure how to do
  • If you "Undo" an action by simply performing the opposite action (i.e. "Undo" a TAB by inserting a SHIFT+TAB), then you are not really undoing anything and it breaks what a user would expect if they hit ctrl-z repeatedly, but that's the only way I can think of so far to "Undo" a TAB or a SHIFT+TAB.

Best,
Tyler

15,640 Comments

@Tyler,

That's a really great question. Someone asked something very similar in response to another post that I had about replacing -- with an mdash automatically (which is really the same concept - intercepting key presses and then changing the value in a custom way):

www.bennadel.com/blog/3483-replacing-double-dashes-with-em-dashes-while-typing-in-javascript.htm

... and, I haven't really spent enough time thinking about it. I think your approach makes sense. I just tried this in Slack, doing a bunch of things, and using -- in the middle of it all, and Slack applies CMD-z in the way that you would expect.

I'll have to do some research to see if there's a way to get this kind of action to integrate more seamlessly. Like, maybe using some sort of lower-level browser API or something???

15,640 Comments

So, I spent the morning trying to figure out how to implement some sort of Undo handler for this kind of text-replacement... and, I couldn't get anything "simple" working. I was hoping there would be a relatively easy way to hook into the Undo event (which is not a "thing"); or, try to replace the key-character with some sort of paste command. But, nothing I did seemed to work at all.

Plus, there's lots of ways to actually trigger an Undo, such as the Edit Menu, which I don't think triggers a key-event (though I didn't test this).

Furthermore, I talked earlier about Slack allowing Undo on the -- replacement. Well, if you open Slack up in the Browser (rather than as the stand-alone Electron app), they don't even replace -- with an mdash :D That was really surprising. I suppose they are using some Electron-specific functionality to do that in the app rather than the kind of technique we are using here.

Long story short, if you want to implement an Undo, I think you would have to store some sort of "stack" in memory and then specifically bind to the CMD+z key-combo and try to apply the Undo programmatically. And, you'd likely have to accept that it's not going to work in all cases.

Granted, I only tried this for 2-hours. So, there's probably more that can be done. But, I just kept hitting road-blocks.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel