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.