Enable Tabbing Within A Fenced Code-Block Inside A Markdown Textarea In JavaScript
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:

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
I just added this functionality to the comments. Written here using tabs:
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:
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 appliesCMD-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.