Transform textarea into code editor with syntax highlighting enabled.
*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:
textarea {
font-family: "Roboto Mono", monospace;
}
Next is to provide some basic CSS properties to define the width
, height
, etc.:
@import url('https:
#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
to nowrap
, the text within the textarea
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;
content = content.replace(/&/g, '&');
content = content.replace(/</g, '<');
content = content.replace(/>/g, '>');
codeBlock.innerHTML = content;
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) {
if (e.key === 'Enter') {
e.preventDefault();
var cursorPos = textarea1.selectionStart;
var prevLine = textarea1.value.substring(0, cursorPos).split('\n').slice(-1)[0];
var indent = prevLine.match(/^\s*/)[0];
textarea1.setRangeText('\n' + indent, cursorPos, cursorPos, 'end');
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 on string
objects in JavaScript that returns a part of the string
between two indices. In this case, substring(0, cursorPos)
is extracting all the text from the start of the textarea
’s value up to the current cursor position. .split('\n')
: This method splits a string
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 the textarea
into lines. .slice(-1)[0]
: The slice
method on arrays returns a shallow copy of a portion of the array. When you call slice(-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 the textarea
up to the cursor position. The [0]
at the end then takes that line out of the single-item array that slice
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 a string
(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”.
This whole regular expression matches all contiguous whitespace characters at the start of a line, which is the indentation. [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) {
if (e.key === "Tab" && !e.shiftKey &&
textarea1.selectionStart == textarea1.selectionEnd) {
e.preventDefault();
let cursorPosition = textarea1.selectionStart;
let newValue = textarea1.value.substring(0, cursorPosition) + " " +
textarea1.value.substring(cursorPosition);
textarea1.value = newValue;
textarea1.selectionStart = cursorPosition + 4;
textarea1.selectionEnd = cursorPosition + 4;
updateCode();
return;
}
}
Add-On 3: Press [Shift]+[Tab] for Decrease Indentation at Current Position
if (e.key === "Tab" && e.shiftKey &&
textarea1.selectionStart == textarea1.selectionEnd) {
e.preventDefault();
let cursorPosition = textarea1.selectionStart;
let leadingSpaces = 0;
for (let i = 0; i < 4; i++) {
if (textarea1.value[cursorPosition - i - 1] === " ") {
leadingSpaces++;
} else {
break;
}
}
if (leadingSpaces > 0) {
let newValue = textarea1.value.substring(0, cursorPosition - leadingSpaces) +
textarea1.value.substring(cursorPosition);
textarea1.value = newValue;
textarea1.selectionStart = cursorPosition - leadingSpaces;
textarea1.selectionEnd = cursorPosition - leadingSpaces;
}
updateCode();
return;
}
Add-On 4: [Tab] / [Shift]+[Tab] for multiline indentation
if (e.key == 'Tab' & textarea1.selectionStart != textarea1.selectionEnd) {
e.preventDefault();
let lines = this.value.split('\n');
let startPos = this.value.substring(0, this.selectionStart).split('\n').length - 1;
let endPos = this.value.substring(0, this.selectionEnd).split('\n').length - 1;
let spacesRemovedFirstLine = 0;
let spacesRemoved = 0;
if (e.shiftKey) {
for (let i = startPos; i <= endPos; i++) {
lines[i] = lines[i].replace(/^ {1,4}/, function (match) {
if (i == startPos)
spacesRemovedFirstLine = match.length;
spacesRemoved += match.length;
return '';
});
}
}
else {
for (let i = startPos; i <= endPos; i++) {
lines[i] = ' ' + lines[i];
}
}
let start = this.selectionStart;
let end = this.selectionEnd;
this.value = lines.join('\n');
this.selectionStart = e.shiftKey ?
start - spacesRemovedFirstLine : start + 4;
this.selectionEnd = e.shiftKey ?
end - spacesRemoved : end + 4 * (endPos - startPos + 1);
updateCode();
return;
}
This block:
this.selectionStart = e.shiftKey ?
start - spacesRemovedFirstLine : start + 4;
can be translated as follows:
if (e.shiftKey) {
this.selectionStart = start - spacesRemovedFirstLine;
}
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();
let startPos = this.value.substring(0, this.selectionStart).split('\n').length - 1;
let endPos = this.value.substring(0, this.selectionEnd).split('\n').length - 1;
let cursorLine = this.value.substring(0, this.selectionStart).split('\n').pop();
let cursorPosInLine = cursorLine.length;
let totalLinesRemove = endPos - startPos + 1;
let lines = this.value.split('\n');
let newStart = lines.slice(0, startPos).join('\n').length + (startPos > 0 ? 1 : 0);
lines.splice(startPos, totalLinesRemove);
let newLine = lines[startPos] || '';
if (newLine.length < cursorPosInLine) {
cursorPosInLine = newLine.length;
}
newStart += cursorPosInLine;
this.value = lines.join('\n');
this.selectionStart = this.selectionEnd = newStart;
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") {
let line = this.value.substring(0, this.selectionStart).split('\n').pop();
let cursorPosInLine = line.length;
let lineStartPos = this.value.substring(0, this.selectionStart).lastIndexOf('\n') + 1;
let firstNonWhitespacePos = line.search(/\S/);
if (firstNonWhitespacePos >= cursorPosInLine) {
return true;
}
else if (firstNonWhitespacePos === -1) {
return true;
}
e.preventDefault();
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.
window.onload = function () {
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