Syntax Highlightning for Textarea (HTML)

Using textarea (HTML) as code editor with syntax highlighting support.

This article is inspired by a free WordPress plugin called Code Block Pro, written by Kevin Batdorf.

While I was working on an update for one of my previous small open source project (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 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 highlightning feature for code editing.

It is not perfect, but I would like to share the idea.

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>

2 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 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 suppose to be broken by itself.

Due to the textarea applies the padding: 15px, it is 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 framework that can do the job. For example: highlight.js and prism.js (which I previously did it 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.cssis 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:

<script>
    function highlightJS() {
        document.querySelectorAll('pre code').forEach((el) => {
            hljs.highlightElement(el);
        });
    }
</script>

For more detail instructions, please refer [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 at 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 highlightning 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 begin.

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

<script>
    let textarea1 = document.getElementById('textarea1');
    let codeBlock = document.getElementById('codeBlock');
</script>

The following javascript will copy the text to the code block:

<script>
    function updateCode() {
                
        let content = textarea1.value;
        
        // encode the special characters
        content = content.replace(/&/g, '&amp;');
        content = content.replace(/</g, '&lt;');
        content = content.replace(/>/g, '&gt;');
        
        // fill the encoded text to the code
        codeBlock.innerHTML = content;
        
        // call highlight.js to render the syntax highligtning
        highlightJS();
    }
</script>

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, '&amp;')
  • replace(/</g, '&lt;')
  • replace(/>/g, '&gt;')

The line content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') replaces the ampersand (&), less than sign (<), and greater than sign (>) with their respective HTML entities (&amp;, &lt;, and &gt;).

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();
});

A javascript event attribute “oninput” can be inserted into the textarea directly to detect the contect changes:

<div id="divCodeWrapper">
    <textarea id="textarea1" wrap="off" spellcheck="false">
    </textarea>
</div>

Stacking Both Elements

As mentioned previously, the div will serve as a container wrapper. This enable both elements (pre+code and textarea) will be stack 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 becomes transparent:

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;
    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:

<script>
    textarea1.addEventListener("scroll", () => {
        codeBlock.scrollTop = textarea1.scrollTop;
        codeBlock.scrollLeft = textarea1.scrollLeft;
    });
</script>

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 (Shortcut Keys)

The following are some add-on functionalities for the textarea.

  1. When [Enter] is hit, maintain indention as previous line
  2. Press [Tab] for indentation at current position
  3. Press [Shift]+[Tab] for decrease indentation at current position
  4. Press [Tab] / [Shift]+[Tab] for multiline indentation
  5. Press [Shift]+[Del]/[Backspace] to delete the entire line
  6. 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');

        // remove focus
        textarea1.blur();

        // regain focus (this is force the textarea scroll to caret position
        // in case the caret falls out the textarea visible area)
        textarea1.focus();

        // 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];

  1. 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.
  2. .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.
  3. .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:

  1. 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.
  2. /^\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.
  3. [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
    var lines = this.value.split('\n');

    // find the start/end lines
    var startPos = this.value.substring(0, this.selectionStart).split('\n').length - 1;
    var endPos = this.value.substring(0, this.selectionEnd).split('\n').length - 1;

    // calculating total white spaces are removed
    // these values will be used for adjusting new cursor position
    var spacesRemovedFirstLine = 0;
    var spacesRemoved = 0;

    // [Shift] key was pressed (this means we're un-indenting)
    if (e.shiftKey) {
    
        // iterate over all lines
        for (var 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 (var i = startPos; i <= endPos; i++) {
            // add a tab to the start of the line
            lines[i] = '    ' + lines[i]; // four spaces
        }
    }

    // remember the cursor position
    var start = this.selectionStart;
    var 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 follow:

// [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 the first use.

<script>
   // wait for all files (css, js) finished laoding
  window.onload = function () {
  
      // use a timer to delay the execution
      // (highlight.js require some time to be ready)
      setTimeout(updateCode, 500);
  };
</script>

It’s done for now. Thank you for reading, and happy coding!