Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

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.



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.

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.

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

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

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

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.