创建支持语法高亮的代码可编辑文本区域

Avatar of Oliver Geer
Oliver Geer

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 价值 $200 的免费积分!

当我正在开发一个需要源代码编辑器组件的项目时,我真的很想有一种方法可以让编辑器突出显示输入的语法。 有像这样的项目,比如 CodeMirrorAceMonaco,但它们都是重量级、功能齐全的编辑器,而不仅仅是我想要的带有语法高亮的 textareas

经过一番努力,我最终做出了一些能够完成这项工作的东西,并想分享我是如何做到的,因为它涉及将一个流行的语法高亮库与 HTML 的编辑功能集成在一起,以及一些需要考虑的有趣边缘情况。

输入时实时语法高亮

在我们深入研究之前,先试用一下吧!

在收到建议后,我还将其作为 GitHub 上的自定义元素 发布,因此您可以在网页中快速使用该组件作为单个 <code-input> 元素。

问题

首先,我尝试在 div 上使用 contenteditable 属性。 我在 div 中输入了一些源代码,并通过 Prism.js(一个流行的语法高亮器)在 oninput 上通过 JavaScript 运行它。 看起来是一个不错的主意,对吧? 我们有一个可以在前端编辑的元素,而 Prism.js 将其语法样式应用于在元素中输入的内容。

Chris 在 此视频 中介绍了如何使用 Prism.js。

但这行不通。 每次元素中的内容发生变化时,DOM 都会被操作,用户的游标会被推回代码的开头,这意味着源代码会反向显示,最后一个字符在开头,第一个字符在结尾。

White monospaced text on a black background that does not spell a coherent word.
反向代码:没什么用

接下来,我尝试使用 <textarea>,但这也不行,因为 textareas 只能包含纯文本。 换句话说,我们无法对输入的内容进行样式设置。 textarea 似乎是唯一一种在没有意外错误的情况下编辑文本的方法——它只是不允许 Prism.js 发挥作用。

当源代码包含在典型的 <pre><code> 标签组合中时,Prism.js 的效果会好得多——它只是缺少方程式的可编辑部分。

因此,它们似乎都不能独立工作。 但我想,为什么不两者兼而有之呢?

解决方案

我在页面中添加了语法高亮的 <pre><code> 一个 textarea,并使用 JavaScript 函数使 <pre><code>innerText 内容在 oninput 时发生更改。 我还在 <pre><code> 结果中添加了一个 aria-hidden 属性,以便屏幕阅读器只读取 <textarea> 中输入的内容,而不是两次大声朗读。

<textarea id="editing" oninput="update(this.value);"></textarea>

<pre id="highlighting" aria-hidden="true">
  <code class="language-html" id="highlighting-content"></code>
</pre>
function update(text) {
  let result_element = document.querySelector("#highlighting-content");
  // Update code
  result_element.innerText = text;
  // Syntax Highlight
  Prism.highlightElement(result_element);
}
The HTML for a link element pointed to CSS-Tricks is in black monospace on a white background above the same HTML link markup, but syntax-highlighted on a black background.
现在它既可编辑又高亮显示!

现在,当编辑 textarea 时——比如,键盘上的一个按下的键弹起来——语法高亮的代码会发生变化。 我们会遇到一些错误,但我想首先关注让它看起来像你正在直接编辑语法高亮的元素,而不是一个单独的 textarea

让它“感觉”像一个代码编辑器

我们的想法是将这些元素在视觉上合并在一起,以便看起来我们正在与一个元素进行交互,而实际上是两个元素在起作用。 我们可以添加一些 CSS,基本上允许 <textarea><pre><code> 元素的大小和间距保持一致。

#editing, #highlighting {
  /* Both elements need the same text and space styling so they are directly on top of each other */
  margin: 10px;
  padding: 10px;
  border: 0;
  width: calc(100% - 32px);
  height: 150px;
}

#editing, #highlighting, #highlighting * {
  /* Also add text styles to highlighting tokens */
  font-size: 15pt;
  font-family: monospace;
  line-height: 20pt;
}

然后我们想要将它们直接定位在彼此的顶部

#editing, #highlighting {
  position: absolute;
  top: 0;
  left: 0;
}

从那里,z-index 允许 textarea 堆叠在突出显示的结果前面

/* Move the textarea in front of the result */
#editing {
  z-index: 1;
}

#highlighting {
  z-index: 0;
}

如果我们到此为止,我们会看到第一个错误。 它看起来不像 Prism.js 正在突出显示语法,但那是因为 textarea 正在遮盖结果。

高亮显示到哪里去了? (提示:它隐藏在后台。)

我们可以用 CSS 修复它! 我们将使 <textarea> 完全透明,除了光标(游标)

/* Make textarea almost completely transparent */
#editing {
  color: transparent;
  background: transparent;
  caret-color: white; /* Or choose your favorite color */
}

啊,好多了!

HTML markup for a link element pointed to CSS tricks in a syntax-highlighted monospace font on a black background.
此链接将带您去哪里? 您决定。

更多修复!

完成这些之后,我尝试使用编辑器一段时间,发现了一些还需要修复的地方。 好消息是,所有问题都非常容易使用 JavaScript、CSS 甚至 HTML 来修复。

删除原生拼写检查

我们正在制作一个代码编辑器,代码中有很多单词和属性,浏览器的原生拼写检查器会认为是拼写错误。

Showing the basic HTML markup for a link with syntax highlighting on a black background, where the href attribute is underlined in red.

拼写检查不是一件坏事;它只是在这种情况下没有用。 是因为拼写错误而标记为错误,还是因为代码无效? 很难说。 要修复此问题,我们只需要将 <textarea> 上的 spellcheck 属性设置为 false

<textarea id="editing" spellcheck="false" ...>

处理换行符

事实证明,innerText 不支持换行符 (\n)。

White monospace text on a black background. The first line says "Hello, World" and the second line says "World" and is highlight in blue.
即使“World!”应该在新的行上,语法高亮的区域也会在同一行上显示它。

update 函数需要编辑。 我们可以使用 innerHTML,而不是使用 innerText,将左括号字符 (<) 替换为 &lt;,并将 & 字符 (&) 替换为 &amp; 以阻止 HTML 转义被评估。 这将阻止创建新的 HTML 标签,允许显示实际的源代码,而不是浏览器尝试呈现代码。

result_element.innerHTML = text.replace(new RegExp("&", "g"), "&").replace(new RegExp("<", "g"), "<"); /* Global RegExp */
Showing the basic HTML document boilerplate with syntax highlighting on a black background. The body element contains the markup yo a paragraph that contains a link that says "CSS-Tricks is brilliant!"

滚动和调整大小

还有另一件事:突出显示的代码在编辑时无法滚动。 当 textarea 滚动时,突出显示的代码不会随其滚动。

首先,让我们确保 textarea 和结果都支持滚动

/* Can be scrolled */
#editing, #highlighting {
  overflow: auto;
  white-space: nowrap; /* Allows textarea to scroll horizontally */
}

然后,为了确保结果 textarea 一起滚动,我们将按照以下方式更新 HTML 和 JavaScript

<textarea id="editing" oninput="update(this.value); sync_scroll(this);" onscroll="sync_scroll(this);"></textarea>
function sync_scroll(element) {
  /* Scroll result to scroll coords of event - sync with textarea */
  let result_element = document.querySelector("#highlighting");
  // Get and set x and y
  result_element.scrollTop = element.scrollTop;
  result_element.scrollLeft = element.scrollLeft;
}

某些浏览器还允许调整 textarea 的大小,但这意味着 textarea 和结果的大小可能不同。 CSS 可以解决这个问题吗? 当然可以。 我们只需禁用调整大小

/* No resize on textarea */
#editing {
  resize: none;
}

最后的换行符

感谢 此评论 指出了此错误。

现在滚动几乎总是同步的,但仍然存在**一个它仍然不起作用的情况**。当用户创建新行时,光标和文本区域的文本在任何文本被输入到新行之前**暂时处于错误的位置**。这是因为**<pre><code>块出于美观原因忽略空行结尾。** 因为这是用于功能性代码输入,而不是显示的代码片段,所以需要显示空行结尾。这是通过**为最后一行提供内容以使其不再为空**来完成的,在update函数中添加了几行 JavaScript。我使用了空格字符,因为它**对用户不可见**。

function update(text) {
  let result_element = document.querySelector("#highlighting-content");
  // Handle final newlines (see article)
  if(text[text.length-1] == "\n") { // If the last character is a newline character
    text += " "; // Add a placeholder space character to the final line 
  }
  // Update code
  result_element.innerHTML = text.replace(new RegExp("&", "g"), "&").replace(new RegExp("<", "g"), "<"); /* Global RegExp */
  // Syntax Highlight
  Prism.highlightElement(result_element);
}

缩进行

调整起来比较棘手的一件事是如何处理结果中的行缩进。编辑器目前的设置方式是,使用空格缩进行可以正常工作。但是,如果您更喜欢使用制表符而不是空格,您可能已经注意到它们没有按预期工作。

可以使用 JavaScript 使 Tab 键正常工作。我在函数中添加了注释以清楚说明正在发生的事情。

<textarea ... onkeydown="check_tab(this, event);"></textarea>
function check_tab(element, event) {
  let code = element.value;
  if(event.key == "Tab") {
    /* Tab key pressed */
    event.preventDefault(); // stop normal
    let before_tab = code.slice(0, element.selectionStart); // text before tab
    let after_tab = code.slice(element.selectionEnd, element.value.length); // text after tab
    let cursor_pos = element.selectionEnd + 1; // where cursor moves after tab - moving forward by 1 char to after tab
    element.value = before_tab + "\t" + after_tab; // add tab char
    // move cursor
    element.selectionStart = cursor_pos;
    element.selectionEnd = cursor_pos;
    update(element.value); // Update text to include indent
  }
}

为了确保**Tab 字符在<textarea>和语法高亮代码块中大小相同**,请编辑CSS以包含 tab-size 属性

#editing, #highlighting, #highlighting * {
  /* Also add text styles to highlighing tokens */
  [...]
  tab-size: 2;
}

最终结果

不算太疯狂,对吧?我们只有<textarea><pre><code>元素在 HTML 中,几行 CSS 将它们堆叠在一起,以及一个语法高亮库来格式化输入的内容。我最喜欢的是,我们正在使用普通的语义化 HTML 元素,利用原生属性来获得我们想要的行为,依靠 CSS 来创造我们只与一个元素交互的错觉,然后借助 JavaScript 来解决一些边缘情况。

虽然我使用了 Prism.js 进行语法高亮,但这种技术可以与其他工具配合使用。如果你愿意,它甚至可以与你自己创建的语法高亮程序一起使用。我希望这能派上用场,并在很多地方使用,无论是 CMS 的 WYSIWYG 编辑器,还是需要输入源代码的表单,例如前端工作申请或问卷调查。毕竟它是一个<textarea>,所以它可以在任何表单中使用——你甚至可以添加一个placeholder,如果你需要的话!