文本区域(HTML)的语法高亮显示
使用文本区域(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 属性来定义 width
、height
等。
@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
设置为nowrap
,textarea
中的文本将显示为一条连续的线,到达边缘时不会换行。 - 在编程中,行不应自行换行。
由于 textarea
应用了 padding: 15px
,因此 textarea
的宽度和高度都设置为 calc(100% - 30px)
,这是减去了左右内边距(或上下内边距)之和 15px 乘以 2。现在 textarea
将填满整个 div
容器。
注意:calc(100%-30px)
是错误的,而 calc(100% - 30px)
是正确的。CSS 计算函数的操作符之间必须留有空格。
语法高亮
接下来,对于语法高亮部分,有一些非常好的 JavaScript 框架可以完成这项工作。例如:highlight.js 和 prism.js(我之前在一个 测试项目 中使用过)。
这是它的工作原理
步骤 1
将编程代码包装在 pre
和 code
标签内。在 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.js 或 prism.js)不为 textarea
提供语法高亮。
由于 highlight.js 只能渲染 pre + code
块内的文本,我采取了一个变通方法。我让 pre+code
和 textarea
堆叠在一起。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, '&');
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();
}
在上面的例子中,textarea
的内容被复制到一个名为 content
的变量中,然后 content
经过三轮字符替换。
replace(/&/g, '&')
replace(/</g, '<')
replace(/>/g, '>')
代码行 content.replace(/&/g, '&'
).replace(/</g, '<').replace(/>/g, '>')
将 ampersand (&
)、小于号 (<
) 和大于号 (>
) 替换为它们各自的 HTML 实体 (&
, <
, and >
)。
这三个特殊字符(&
、<
和 >
)需要被编码(转义),这样它们在 HTML 中就会失去其原始含义,并可以正确地作为文本显示给用户。
接下来,JavaScript 函数 updateCode()
会在 textarea
内容发生更改时(例如编辑、剪切和粘贴等)实时触发。
为 textarea 添加一个“input
”的 JavaScript 事件监听器
textarea1.addEventListener("input", () => {
updateCode();
});
堆叠两个元素
如前所述,div
将作为容器包装器。这使得两个元素(pre+code
和 textarea
)能够堆叠在 div
中。
<div id="divCodeWrapper">
<pre id="preCode"><code id="codeBlock"></code></pre>
<textarea ID="textarea1" wrap="false" spellcheck="false">
</textarea>
</div>
首先,将 div
的 position
设置为 relative
。
#divCodeWrapper {
height: 600px;
width: 900px;
overflow: hidden;
border: 1px solid #a5a5a5;
position: relative;
}
通过这样做,当两个元素(pre+code
和 textarea
)应用 position = absolute
的效果时,它们将被限制在 div
内部。接下来,通过定义它们的 CSS
属性 top=0
和 left=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
的附加功能。
- 按下 [Enter] 时,保持与上一行相同的缩进
- 按下 [Tab] 在当前位置进行缩进
- 按下 [Shift]+[Tab] 在当前位置减少缩进
- 按下 [Tab] / [Shift]+[Tab] 进行多行缩进
- 按下 [Shift]+[Del]/[Backspace] 删除整行
- 按下 [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];
substring
是 JavaScript 中string
对象的一个方法,它返回string
中两个索引之间的部分。在这种情况下,substring(0, cursorPos)
提取了从textarea
值开头到当前光标位置的所有文本。.split('\n')
:此方法将string
分割成子字符串数组,并使用提供的参数作为分隔符。在这种情况下,分隔符是\n
,即换行符。因此,此方法调用将textarea
中的文本分割成行。.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
开头的空白字符,这代表了上一行的缩进。
让我们分解一下它的各个部分
prevLine.match()
– 此函数在string
(prevLine
)上调用,并以正则表达式作为参数。它返回所有匹配项的数组。/^\s*/
– 这是使用的正则表达式^
– 此符号表示“行首”。匹配必须从行的第一个字符开始。\s
– 此符号匹配任何空白字符。这包括空格、制表符和其他形式的空白。*
– 此符号表示“0
个或多个前面的元素”。因此,\s*
表示“0
个或多个空白字符”。
[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 - 对源代码文件进行了少量清理,进行了一些更新