Syntax Highlightning for Textarea (HTML)
Using textarea (HTML) as code editor with syntax highlighting support
*Update (30th May, 2023): Improved JavaScript for indentation
Introduction
This article is inspired by a free WordPress plugin called [Code Block Pro], written by Kevin Batdorf.
Acknowledgement: The research and completion of this small project and writing of the article is assisted by ChatGPT.
While I was working on an update for one of my previous small open source projects (a live demo site for Generating PDF by Using Microsoft Edge), an idea was sparked in my mind: "Why not enable syntax highlighting for a textarea
?" (There is a textarea
that serves as an editor at the page for user to test custom HTML for generating PDF).
I was excited about the idea. After some research and testing, I successfully built up a textarea
that has a syntax highlighting feature for code editing.
It is not perfect, but I would like to share the idea.
Here we go.
Let’s start with a simple textarea
wrapped inside a div
. The div
will serve as a container that provides the definition of width and height for the textarea
.
<div id="divCodeWrapper">
<textarea id="textarea1" wrap="soft" spellcheck="false">
</textarea>
</div>
Two initial attributes applied to the textarea
. wrap="soft"
tells the textarea
not to break lines, and spellcheck="false"
tells the user browser that the textarea
should never check and highlight any spelling error.
To transform textarea
as code editor, the very basic first thing is to apply a monospace font. Here, I import a monospace font called Roboto Mono
from [Google Fonts].
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400&display=swap');
textarea {
font-family: "Roboto Mono", monospace;
}
Next is to provide some basic CSS properties to define the width
, height
, etc.:
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400&display=swap');
#divCodeWrapper {
height: 500px;
width: 900px;
overflow: hidden;
border: 1px solid #a5a5a5;
}
textarea {
font-family: "Roboto Mono", monospace;
font-weight: 400;
font-size: 10pt;
line-height: 150%;
overflow-x: auto;
overflow-y: scroll;
white-space: nowrap;
padding: 15px;
height: calc(100% - 30px);
width: calc(100% - 30px);
}
white-space: nowrap;
- By setting
white-space
tonowrap
, the text within thetextarea
will appear as a single continuous line without wrapping to the next line and causing line breaks when it reaches the edge. - In coding, lines are not supposed to be broken by itself.
Due to the textarea
applying the padding: 15px
, therefore, both width and height of textarea
are set to calc(100% - 30px)
, which is minus off the sum of 15px left and right padding (or 15px top and bottom padding). The textarea
will now fill up the whole div
container.
Note: calc(100%-30px)
is wrong, and calc(100% - 30px)
is correct. There must have space in between the operator for CSS calculation function.
Syntax Highlighting
Next, for the syntax highlighting part, there are some very nice JavaScript frameworks that can do the job. For example: highlight.js and prism.js (which I previously did on a test project).
Here is how it works:
Step 1
Wrap the programming code inside a pre
and code
tag. Define the programming language in the class
attribute inside the code
tag. Example:
<pre><code class="language-html">.. write code here.... </code></pre>
Download the JavaScript and CSS from their [official website].
Step 2
Include the JavaScript library into the page.
<link href="vs2015.min.css" rel="stylesheet" />
<script src="highlight.min.js"></script>
vs2015.min.css is one of the theme files; there are many pre-built theme files to choose from.
Step 3
Execute JavaScript script to initiate the highlighting task:
function highlightJS() {
document.querySelectorAll('pre code').forEach((el) => {
hljs.highlightElement(el);
});
}
For more detailed instructions, please refer to their documentation.
But here’s the problem: the JavaScript framework (highlight.js or prism.js) does not provide syntax highlighting for the textarea
.
Since highlight.js can only renders text within a pre + code
block, I did a walkaround. I made the pre+code
and textarea
stacking on each other. Textarea
will be at the front, and the pre+code
will be behind. Copy the content in textarea
to the code
block in real time and render it with highlight.js.
Make the textarea
transparent. The textarea
will handle the user input and the pre+code
will be responsible for showing the rendered syntax highlighting to user. Since both elements are stacking exactly on top of each other, it gives an illusion to the user that they seem to appear as one element.
Let's Stack
Provide an ID
to the code
block, is for JavaScript calling.
<pre id="preCode"><code id="codeBlock"></code></pre>
Declare a global variable to hold the elements:
let textarea1 = document.getElementById('textarea1');
let codeBlock = document.getElementById('codeBlock');
The following JavaScript will copy the text to the code
block:
function updateCode() {
let content = textarea1.value;
// encode the special characters
content = content.replace(/&/g, '&');
content = content.replace(/</g, '<');
content = content.replace(/>/g, '>');
// /& = look for this symbol
// /g = global: find all occurrences (not just the first occurrence)
// fill the encoded text to the code
codeBlock.innerHTML = content;
// call highlight.js to render the syntax highlighting
highlightJS();
}
In the above example, the content from the textarea
is copied into a variable called content
, then content
undergoes three rounds of character replacement.
replace(/&/g, '&')
replace(/</g, '<')
replace(/>/g, '>')
The line content.replace(/&/g, '&'
).replace(/</g, '<').replace(/>/g, '>')
replaces the ampersand (&
), less than sign (<
), and greater than sign (>
) with their respective HTML entities (&
, <
, and >
).
These three special characters (&
, <
, and >
) need to be encoded (escaped) so that they will lose their original meaning in HTML and can be displayed properly as text to the user.
Next, the JavaScript function updateCode()
is triggered in real time whenever there are changes to the content of the textarea
, such as editing, cutting and pasting, etc…
Add a JavaScript event listener of "input
" to the textarea:
textarea1.addEventListener("input", () => {
updateCode();
});
Stacking Both Elements
As mentioned previously, the div
will serve as a container wrapper. This enables both elements (pre+code
and textarea
) to be stacked within the div
.
<div id="divCodeWrapper">
<pre id="preCode"><code id="codeBlock"></code></pre>
<textarea ID="textarea1" wrap="false" spellcheck="false">
</textarea>
</div>
First, mark the position
of div
become relative
.
#divCodeWrapper {
height: 600px;
width: 900px;
overflow: hidden;
border: 1px solid #a5a5a5;
position: relative;
}
With this, when both elements (pre+code
and textarea
) apply the effect of position = absolute
, they will be trapped within the div
. Next, define the starting point where both elements will stack together, which is by defining their CSS
properties of top=0
and left=0
. Zero distance from top left edge of parent element (the div
).
#preCode {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
padding: 0;
margin: 0;
background: #1b1b1b;
}
#preCode code {
padding: 15px;
height: calc(100% - 30px);
width: calc(100% - 30px);
font-family: "Roboto Mono", monospace;
font-weight: 400;
font-size: 10pt;
line-height: 150%;
overflow-y: scroll;
overflow-x: auto;
}
textarea {
font-family: "Roboto Mono", monospace;
font-weight: 400;
font-size: 10pt;
line-height: 150%;
position: absolute;
top: 0;
left: 0;
height: calc(100% - 30px);
width: calc(100% - 30px);
padding: 15px;
z-index: 2;
overflow-x: auto;
overflow-y: scroll;
white-space: nowrap;
}
Now, both elements are stacked together.
Next is to add CSS properties to make the textarea
become transparent:
textarea {
background-color: rgba(0,0,0,0);
color: rgba(0,0,0,0);
caret-color: white;
}
Next, I sync the scrolling position of the textarea
with the code
block by adding a JavaScript event listener (scroll
) to the textarea
:
textarea1.addEventListener("scroll", () => {
codeBlock.scrollTop = textarea1.scrollTop;
codeBlock.scrollLeft = textarea1.scrollLeft;
});
So now, the code
block will automatically scroll exactly as the textarea
.
Up until this step, the work above has essentially achieved the initial purpose of providing syntax highlighting support for code editing in a textarea
.
Additional Add-On Functionality
The following are some add-on functionalities for the textarea
.
- When [Enter] is hit, maintain indention as previous line
- Press [Tab] for indentation at current position
- Press [Shift]+[Tab] for decrease indentation at current position
- Press [Tab] / [Shift]+[Tab] for multiline indentation
- Press [Shift]+[Del]/[Backspace] to delete the entire line
- Press [Home] to move the cursor to the front of the first non-white space character
Add-on 1: When [Enter] is Hit, Maintain Indention as Previous Line
textarea1.addEventListener('keydown', function (e) {
// [Enter] key pressed detected
if (e.key === 'Enter') {
// Prevent the default behavior (new line)
e.preventDefault();
// Get the cursor position
var cursorPos = textarea1.selectionStart;
// Get the previous line
var prevLine = textarea1.value.substring(0, cursorPos).split('\n').slice(-1)[0];
// Get the indentation of the previous line
var indent = prevLine.match(/^\s*/)[0];
// Add a new line with the same indentation
textarea1.setRangeText('\n' + indent, cursorPos, cursorPos, 'end');
// copy the code from textarea to code block
updateCode();
return;
}
}
The following explains some of the JavaScript code seen above:
textarea1.value.substring(0, cursorPos).split('\n').slice(-1)[0];
substring
is a method onstring
objects in JavaScript that returns a part of thestring
between two indices. In this case,substring(0, cursorPos)
is extracting all the text from the start of thetextarea
’s value up to the current cursor position..split('\n')
: This method splits astring
into an array of substrings, and it uses the argument as the delimiter. In this case, the delimiter is\n
, which is the newline character. So this method call is splitting the text from thetextarea
into lines..slice(-1)[0]
: Theslice
method on arrays returns a shallow copy of a portion of the array. When you callslice(-1)
, it’s asking for a new array that contains just the last element of the original array. In other words, it’s getting the last line of thetextarea
up to the cursor position. The[0]
at the end then takes that line out of the single-item array thatslice
returned.
So the whole line, textarea.value.substring(0, cursorPos).split('\n').slice(-1)[0]
, is getting the text of the current line in the textarea
, i.e., the line where the cursor is currently positioned.
Next, prevLine.match(/^\s*/)[0];
This line is using a regular expression to match the leading whitespace characters at the start of prevLine
, which represents the indentation from the previous line.
Let’s break down the different parts of it:
prevLine.match()
– This function is called on astring
(prevLine
), and it takes a regular expression as an argument. It returns an array of all matches./^\s*/
– This is the regular expression being used:^
– This symbol means “start of line“. The match has to start from the first character of the line.\s
– This symbol matches any whitespace character. This includes spaces, tabs, and other forms of whitespace.*
– This symbol means “0
or more of the preceding element”. So,\s*
means “0
or more whitespace characters”.
[0]
– After.match()
returns an array of matches,[0]
is used to access the first match. In this case, since the regular expression starts with^
, which means “start of line“, there will be only one match. So,[0]
will return the matched whitespace characters from the start of the line.
The entire line of code returns the leading whitespace characters from prevLine
, preserving the indentation for the next line.
Add-On 2: Press [Tab] for Indentation at Current Position
textarea1.addEventListener('keydown', function (e) {
// [Tab] pressed, but no [Shift]
if (e.key === "Tab" && !e.shiftKey &&
// and no highlight detected
textarea1.selectionStart == textarea1.selectionEnd) {
// suspend default behaviour
e.preventDefault();
// Get the current cursor position
let cursorPosition = textarea1.selectionStart;
// Insert 4 white spaces at the cursor position
let newValue = textarea1.value.substring(0, cursorPosition) + " " +
textarea1.value.substring(cursorPosition);
// Update the textarea value and cursor position
textarea1.value = newValue;
textarea1.selectionStart = cursorPosition + 4;
textarea1.selectionEnd = cursorPosition + 4;
// copy the code from textarea to code block
updateCode();
return;
}
}
Add-On 3: Press [Shift]+[Tab] for Decrease Indentation at Current Position
// [Tab] and [Shift] keypress presence
if (e.key === "Tab" && e.shiftKey &&
// no highlight detected
textarea1.selectionStart == textarea1.selectionEnd) {
// suspend default behaviour
e.preventDefault();
// Get the current cursor position
let cursorPosition = textarea1.selectionStart;
// Check the previous characters for spaces
let leadingSpaces = 0;
for (let i = 0; i < 4; i++) {
if (textarea1.value[cursorPosition - i - 1] === " ") {
leadingSpaces++;
} else {
break;
}
}
if (leadingSpaces > 0) {
// Remove the spaces
let newValue = textarea1.value.substring(0, cursorPosition - leadingSpaces) +
textarea1.value.substring(cursorPosition);
// Update the textarea value and cursor position
textarea1.value = newValue;
textarea1.selectionStart = cursorPosition - leadingSpaces;
textarea1.selectionEnd = cursorPosition - leadingSpaces;
}
// copy the code from textarea to code block
updateCode();
return;
}
Add-On 4: [Tab] / [Shift]+[Tab] for multiline indentation
// [Tab] key pressed and range selection detected
if (e.key == 'Tab' & textarea1.selectionStart != textarea1.selectionEnd) {
e.preventDefault();
// split the textarea content into lines
let lines = this.value.split('\n');
// find the start/end lines
let startPos = this.value.substring(0, this.selectionStart).split('\n').length - 1;
let endPos = this.value.substring(0, this.selectionEnd).split('\n').length - 1;
// calculating total removed white spaces
// these values will be used for adjusting new cursor position
let spacesRemovedFirstLine = 0;
let spacesRemoved = 0;
// [Shift] key was pressed (this means we're un-indenting)
if (e.shiftKey) {
// iterate over all lines
for (let i = startPos; i <= endPos; i++) {
// /^ = from the start of the line,
// {1,4} = remove in between 1 to 4 white spaces that may existed
lines[i] = lines[i].replace(/^ {1,4}/, function (match) {
// "match" is a string (white space) extracted
// obtaining total white spaces removed
// total white space removed at first line
if (i == startPos)
spacesRemovedFirstLine = match.length;
// total white space removed overall
spacesRemoved += match.length;
return '';
});
}
}
// no shift key, so we're indenting
else {
// iterate over all lines
for (let i = startPos; i <= endPos; i++) {
// add a tab to the start of the line
lines[i] = ' ' + lines[i]; // four spaces
}
}
// remember the cursor position
let start = this.selectionStart;
let end = this.selectionEnd;
// put the modified lines back into the textarea
this.value = lines.join('\n');
// adjust the position of cursor start selection
this.selectionStart = e.shiftKey ?
start - spacesRemovedFirstLine : start + 4;
// adjust the position of cursor end selection
this.selectionEnd = e.shiftKey ?
end - spacesRemoved : end + 4 * (endPos - startPos + 1);
// copy the code from textarea to code block
updateCode();
return;
}
This block:
this.selectionStart = e.shiftKey ?
start - spacesRemovedFirstLine : start + 4;
can be translated as follows:
// [Shift] key pressed (decrease indentation)
if (e.shiftKey) {
this.selectionStart = start - spacesRemovedFirstLine;
}
// [Shift] key not presence (increase indentation)
else {
this.selectionStart = start + 4;
}
Add-On 5: Press [Shift]+[Del]/[Backspace] to delete the entire line
if (e.shiftKey && (e.key === "Delete" || e.key === "Backspace")) {
e.preventDefault();
// find the start/end lines
let startPos = this.value.substring(0, this.selectionStart).split('\n').length - 1;
let endPos = this.value.substring(0, this.selectionEnd).split('\n').length - 1;
// get the line and the position in that line where the cursor is
// pop() = take out the last line (which is the cursor selection start located)
let cursorLine = this.value.substring(0, this.selectionStart).split('\n').pop();
// get the position of cursor within the last line
let cursorPosInLine = cursorLine.length;
// calculating total lines to be removed
let totalLinesRemove = endPos - startPos + 1;
// split the textarea content into lines
let lines = this.value.split('\n');
// calculate new cursor position
let newStart = lines.slice(0, startPos).join('\n').length + (startPos > 0 ? 1 : 0);
// add 1 if startPos > 0 to account for '\n' character
// remove the selected lines
lines.splice(startPos, totalLinesRemove);
// get the new line where the cursor will be after deleting lines
// if lines[startPos] is not existed, then the new line will be an empty string
let newLine = lines[startPos] || '';
// if the new line is shorter than the cursor position, put the cursor at the end of the line
if (newLine.length < cursorPosInLine) {
cursorPosInLine = newLine.length;
}
// adjuct the cursor's position in the line to the new cursor position
newStart += cursorPosInLine;
// put the modified lines back into the textarea
this.value = lines.join('\n');
// set the new cursor position
// both cursor selection start and end will be at the same position
this.selectionStart = this.selectionEnd = newStart;
// copy the code from textarea to code block
updateCode();
return;
}
Add-On 6: Press [Home] to move the cursor to the front of the first non-white space character
if (e.key === "Home") { // get the line and the position in that line where the cursor is // pop() = take out the last line (which is the cursor selection start located) let line = this.value.substring(0, this.selectionStart).split('\n').pop(); // get the position of cursor within the last line let cursorPosInLine = line.length; // Find the start of the current line let lineStartPos = this.value.substring(0, this.selectionStart).lastIndexOf('\n') + 1; // Find the first non-whitespace character on the line let firstNonWhitespacePos = line.search(/\S/); // the cursor's position is already in front of first non-whitespace character, // or it's position is before first none-whitespace character, // move the cursor to the start of line if (firstNonWhitespacePos >= cursorPosInLine) { // do nothing, perform default behaviour, which is moving the cursor to beginning of the line return true; } // If there's no non-whitespace character, this is an empty or whitespace-only line else if (firstNonWhitespacePos === -1) { // do nothing, perform default behaviour, which is moving the cursor to beginning of the line return true; } // Prevent the default Home key behavior e.preventDefault(); // Move the cursor to the position of the first non-whitespace character this.selectionStart = this.selectionEnd = lineStartPos + firstNonWhitespacePos; return; }
Delay the Execution of Highlight.js for the First Time
Finally, an initial delay is provided for highlight.js to be ready for first use.
// wait for all files (css, js) finished loading
window.onload = function () {
// use a timer to delay the execution
// (highlight.js require some time to be ready)
setTimeout(updateCode, 500);
};
It's done for now. Thank you for reading, and happy coding.
History
- 27th May, 2023 - First published
- 30th May, 2023 - Version 2.0 - Improved JavaScript for indentation, fixed some minor bugs
- 3rd June, 2023 - Version 2.4 - Minor clean up of the source code file, some update