65.9K
CodeProject 正在变化。 阅读更多。
Home

文本区域(HTML)的语法高亮显示

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2023 年 5 月 26 日

CPOL

8分钟阅读

viewsIcon

13518

downloadIcon

272

使用文本区域(HTML)作为具有语法高亮显示支持的代码编辑器。

*更新(2023年5月30日):改进了用于缩进的 JavaScript

引言

本文的灵感来自一款名为 [Code Block Pro] 的免费 WordPress 插件,由 Kevin Batdorf 编写。

致谢:ChatGPT 协助完成了这个小型项目和文章的撰写。

在我更新之前一个小型开源项目(一个用于“使用 Microsoft Edge 生成 PDF”的实时演示网站)时,我脑海中闪过一个想法:“为什么不为 textarea 启用语法高亮呢?”(该页面上有一个 textarea 作为编辑器,供用户测试用于生成 PDF 的自定义 HTML)。

我对此想法感到兴奋。经过一些研究和测试,我成功构建了一个具有代码编辑语法高亮功能的 textarea

它并不完美,但我愿意分享这个想法。

开始吧。

让我们从一个简单的 textarea 开始,将其包装在一个 div 中。该 div 将作为容器,提供 textarea 的宽度和高度定义。

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

textarea 应用了两个初始属性。wrap="soft" 告诉 textarea 不要换行,而 spellcheck="false" 告知用户浏览器 textarea 永远不应检查和高亮任何拼写错误。

要将 textarea 转换为代码编辑器,最基本的第一步是应用一个等宽字体。在这里,我从 [Google Fonts] 导入了一个名为 Roboto Mono 的等宽字体。

@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400&display=swap');

textarea {
    font-family: "Roboto Mono", monospace;
}

接下来是提供一些基本的 CSS 属性来定义 widthheight 等。

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

  • 通过将 white-space 设置为 nowraptextarea 中的文本将显示为一条连续的线,到达边缘时不会换行。
  • 在编程中,行不应自行换行。

由于 textarea 应用了 padding: 15px,因此 textarea 的宽度和高度都设置为 calc(100% - 30px),这是减去了左右内边距(或上下内边距)之和 15px 乘以 2。现在 textarea 将填满整个 div 容器。

注意calc(100%-30px) 是错误的,而 calc(100% - 30px) 是正确的。CSS 计算函数的操作符之间必须留有空格。

语法高亮

接下来,对于语法高亮部分,有一些非常好的 JavaScript 框架可以完成这项工作。例如:highlight.jsprism.js(我之前在一个 测试项目 中使用过)。

这是它的工作原理

步骤 1

将编程代码包装在 precode 标签内。在 code 标签内的 class 属性中定义编程语言。例如:

<pre><code class="language-html">.. write code here.... </code></pre>

从他们的 [官方网站] 下载 JavaScript 和 CSS。

第二步

将 JavaScript 库包含到页面中。

<link href="vs2015.min.css" rel="stylesheet" />
<script src="highlight.min.js"></script>

vs2015.min.css 是其中一个主题文件;有许多预先构建的主题文件可供选择。

步骤 3

执行 JavaScript 脚本以启动高亮任务

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

有关更详细的说明,请参阅 他们的文档

但问题是:JavaScript 框架(highlight.jsprism.js)不为 textarea 提供语法高亮。

由于 highlight.js 只能渲染 pre + code 块内的文本,我采取了一个变通方法。我让 pre+codetextarea 堆叠在一起。Textarea 在前面,pre+code 在后面。实时将 textarea 的内容复制到 code 块中,并用 highlight.js 进行渲染。

使 textarea 透明。Textarea 将处理用户输入,而 pre+code 将负责向用户显示渲染的语法高亮。由于这两个元素精确地堆叠在一起,因此会给用户一种它们看起来像一个元素的错觉。

开始堆叠

code 块提供一个 ID,供 JavaScript 调用。

<pre id="preCode"><code id="codeBlock"></code></pre>

声明一个全局变量来保存元素

let textarea1 = document.getElementById('textarea1');
let codeBlock = document.getElementById('codeBlock');

以下 JavaScript 将文本复制到 code 块中

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;');

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

在上面的例子中,textarea 的内容被复制到一个名为 content 的变量中,然后 content 经过三轮字符替换。

  • replace(/&/g, '&')
  • replace(/</g, '&lt;')
  • replace(/>/g, '&gt;')

代码行 content.replace(/&/g, '&').replace(/</g, '&lt;').replace(/>/g, '&gt;') 将 ampersand (&)、小于号 (<) 和大于号 (>) 替换为它们各自的 HTML 实体 (&, &lt;, and &gt;)。

这三个特殊字符(&<>)需要被编码(转义),这样它们在 HTML 中就会失去其原始含义,并可以正确地作为文本显示给用户。

接下来,JavaScript 函数 updateCode() 会在 textarea 内容发生更改时(例如编辑、剪切和粘贴等)实时触发。

为 textarea 添加一个“input”的 JavaScript 事件监听器

textarea1.addEventListener("input", () => {
    updateCode();
});

堆叠两个元素

如前所述,div 将作为容器包装器。这使得两个元素(pre+codetextarea)能够堆叠在 div 中。

<div id="divCodeWrapper">
    <pre id="preCode"><code id="codeBlock"></code></pre>
    <textarea ID="textarea1" wrap="false" spellcheck="false">
    </textarea>
</div>

首先,将 divposition 设置为 relative

#divCodeWrapper {
    height: 600px;
    width: 900px;
    overflow: hidden;
    border: 1px solid #a5a5a5;
    position: relative;
}

通过这样做,当两个元素(pre+codetextarea)应用 position = absolute 的效果时,它们将被限制在 div 内部。接下来,通过定义它们的 CSS 属性 top=0left=0 来定义两个元素堆叠在一起的起始点。与父元素(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;
}

现在,两个元素堆叠在一起了。

接下来是添加 CSS 属性使 textarea 变得透明

textarea {
    background-color: rgba(0,0,0,0);
    color: rgba(0,0,0,0);
    caret-color: white;
}

接下来,我通过向 textarea 添加一个 JavaScript 事件监听器(scroll)来同步 textarea 的滚动位置与 code 块。

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

因此,现在 code 块将与 textarea 精确同步滚动。

到此为止,上述工作基本上实现了为 textarea 中的代码编辑提供语法高亮支持的最初目的。

附加功能

以下是一些 textarea 的附加功能。

  1. 按下 [Enter] 时,保持与上一行相同的缩进
  2. 按下 [Tab] 在当前位置进行缩进
  3. 按下 [Shift]+[Tab] 在当前位置减少缩进
  4. 按下 [Tab] / [Shift]+[Tab] 进行多行缩进
  5. 按下 [Shift]+[Del]/[Backspace] 删除整行
  6. 按下 [Home] 将光标移到第一个非空白字符的前面

附加功能 1:按下 [Enter] 时,保持与上一行相同的缩进

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

以下解释了上面看到的某些 JavaScript 代码

textarea1.value.substring(0, cursorPos).split('\n').slice(-1)[0];
  1. substring 是 JavaScript 中 string 对象的一个方法,它返回 string 中两个索引之间的部分。在这种情况下,substring(0, cursorPos) 提取了从 textarea 值开头到当前光标位置的所有文本。
  2. .split('\n'):此方法将 string 分割成子字符串数组,并使用提供的参数作为分隔符。在这种情况下,分隔符是 \n,即换行符。因此,此方法调用将 textarea 中的文本分割成行。
  3. .slice(-1)[0]:数组上的 slice 方法返回数组一部分的浅拷贝。当调用 slice(-1) 时,它会请求一个包含原始数组最后一个元素的新数组。换句话说,它获取了 textarea 中光标位置之前的最后一行。末尾的 [0] 然后将该行从 slice 返回的单元素数组中取出。

因此,整行代码 textarea.value.substring(0, cursorPos).split('\n').slice(-1)[0] 获取了 textarea 中当前行的文本,即光标当前所在行的文本。

接下来,prevLine.match(/^\s*/)[0];

此行使用正则表达式匹配 prevLine 开头的空白字符,这代表了上一行的缩进。

让我们分解一下它的各个部分

  1. prevLine.match() – 此函数在 stringprevLine)上调用,并以正则表达式作为参数。它返回所有匹配项的数组。
  2. /^\s*/ – 这是使用的正则表达式
    • ^ – 此符号表示“行首”。匹配必须从行的第一个字符开始。\s – 此符号匹配任何空白字符。这包括空格、制表符和其他形式的空白。* – 此符号表示“0 个或多个前面的元素”。因此,\s* 表示“0 个或多个空白字符”。
    整个正则表达式匹配一行开头的所有连续空白字符,这就是缩进。
  3. [0] – 在 .match() 返回匹配项的数组后,[0] 用于访问第一个匹配项。在这种情况下,由于正则表达式以 ^ 开头,表示“行首”,因此只有一个匹配项。因此,[0] 将返回从行首匹配的空白字符。

整行代码返回 prevLine 的前导空白字符,从而为下一行保留缩进。

附加功能 2:按下 [Tab] 在当前位置进行缩进

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

附加功能 3:按下 [Shift]+[Tab] 在当前位置减少缩进

// [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;
}

附加功能 4:[Tab] / [Shift]+[Tab] 进行多行缩进

// [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.selectionStart = e.shiftKey ? 
    start - spacesRemovedFirstLine : start + 4;

可以翻译为:

// [Shift] key pressed (decrease indentation)
if (e.shiftKey) {
    this.selectionStart = start - spacesRemovedFirstLine;
}
// [Shift] key not presence (increase indentation)
else {
    this.selectionStart = start + 4;
}

附加功能 5:按下 [Shift]+[Del]/[Backspace] 删除整行

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

附加功能 6:按下 [Home] 将光标移到第一个非空白字符的前面

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

延迟 Highlight.js 首次执行

最后,为 highlight.js 提供了初始延迟,以便首次使用。

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

暂时完成了。感谢您的阅读,祝您编码愉快。

历史

  • 2023 年 5 月 27 日 - 首次发布
  • 2023 年 5 月 30 日 - 版本 2.0 - 改进了用于缩进的 JavaScript,修复了一些小错误
  • 2023 年 6 月 3 日 - 版本 2.4 - 对源代码文件进行了少量清理,进行了一些更新
© . All rights reserved.