使用 EditContext API

**EditContext API** 可用于在 Web 上构建富文本编辑器,这些编辑器支持高级文本输入体验,例如 输入法编辑器 (IME) 组合、表情符号选择器或任何其他与平台相关的编辑相关 UI 表面。

本文介绍了使用 EditContext API 构建文本编辑器的必要步骤。在本指南中,您将回顾构建一个简单的 HTML 代码编辑器所涉及的主要步骤,该编辑器在您键入时突出显示代码的语法,并支持 IME 组合。

最终代码和实时演示

要查看最终代码,请查看 GitHub 上的 源代码。在阅读时最好保持源代码打开,因为本教程仅显示代码中最重要的部分。

源代码组织在以下文件中

  • index.html 包含编辑器 UI 元素,并加载演示所需的 CSS 和 JavaScript 代码。
  • styles.css 包含编辑器 UI 的样式。
  • editor.js 包含设置编辑器 UI、渲染 HTML 代码和处理用户输入的 JavaScript 代码。
  • tokenizer.js 包含将 HTML 代码拆分为单独标记(例如开始标记、结束标记和文本节点)的 JavaScript 代码。
  • converter.js 包含在 EditContext API 使用的字符偏移量和浏览器用于文本选择的 DOM 节点之间进行转换的 JavaScript 代码。

要使用实时演示,请在支持 EditContext API 的浏览器中打开 Edit Context API:HTML 编辑器演示

创建编辑器 UI

第一步是创建编辑器的 UI。编辑器是一个 <div> 元素,其 spellcheck 属性设置为 false 以禁用拼写检查

html
<div id="html-editor" spellcheck="false"></div>

要设置编辑器元素的样式,使用以下 CSS 代码。该代码使编辑器填充整个视口并在内容过多无法容纳时滚动。还使用 white-space 属性保留 HTML 输入文本中找到的空格字符,并使用 tab-size 属性使制表符字符渲染为两个空格。最后,设置一些默认的背景、文本和光标颜色

css
#html-editor {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  border-radius: 0.5rem;
  padding: 1rem;
  overflow: auto;
  white-space: pre;
  tab-size: 2;
  caret-color: red;
  background: #000;
  line-height: 1.6;
  color: red;
}

使编辑器可编辑

要使网页上的元素可编辑,大多数情况下,您使用 <input> 元素、<textarea> 元素或 contenteditable 属性。

但是,使用 EditContext API,您可以使其他类型的元素可编辑,而无需使用属性。要查看可与 EditContext API 一起使用的元素列表,请参阅 HTMLElement editContext 属性页面上的 可能的元素

要使编辑器可编辑,演示应用程序创建了一个 EditContext 实例,将一些初始 HTML 文本传递给构造函数,然后将编辑器元素的 editContext 属性设置为 EditContext 实例

js
// Retrieve the editor element from the DOM.
const editorEl = document.getElementById("html-editor");

// Create the EditContext instance.
const editContext = new EditContext({
  text: "<html>\n  <body id=foo>\n    <h1 id='header'>Cool Title</h1>\n    <p class=\"wow\">hello<br/>How are you? test</p>\n  </body>\n</html>",
});

// Set the editor's editContext property value.
editorEl.editContext = editContext;

这些代码行使编辑器元素可聚焦。在元素中输入文本会在 EditContext 实例上触发 textupdate 事件。

渲染文本和用户选择

为了在用户输入文本时渲染编辑器中语法高亮的 HTML 代码,演示应用程序使用了一个名为 render() 的函数,该函数在输入新文本、删除字符或更改选择时被调用。

标记化 HTML 代码

render() 函数首先执行的操作之一是对 HTML 文本内容进行标记化。标记化 HTML 文本内容是突出显示 HTML 语法所必需的,它涉及读取 HTML 代码字符串,并确定每个开始标记、结束标记、属性、注释节点和文本节点的起始和结束位置。

演示应用程序使用 tokenizeHTML() 函数来实现此目的,该函数逐字符迭代字符串,同时维护一个状态机。您可以在 GitHub 上的 tokenizer.js 中查看 tokenizeHTML() 函数的源代码。

该函数像这样导入到演示应用程序 HTML 文件中

js
import { tokenizeHTML } from "./tokenizer.js";

渲染文本

每当调用 render() 函数时(即用户输入文本或选择发生更改时),该函数都会删除编辑器元素中的内容,然后将每个标记渲染为一个单独的 HTML 元素

js
// Stores the list of HTML tokens.
let currentTokens = [];

function render(text, selectionStart, selectionEnd) {
  // Empty the editor. We're re-rendering everything.
  editorEl.textContent = "";

  // Tokenize the text.
  currentTokens = tokenizeHTML(text);

  for (const token of currentTokens) {
    // Render each token as a span element.
    const span = document.createElement("span");
    span.classList.add(`token-${token.type}`);
    span.textContent = token.value;

    // Attach the span to the editor element.
    editorEl.appendChild(span);

    // Store the new DOM node as a property of the token
    // in the currentTokens array. We will need it again
    // later in fromOffsetsToRenderedTokenNodes.
    token.node = span;
  }

  // Code to render the text selection is omitted for brevity.
  // See "Rendering the selection", below.
  // ...
}

EditContext API 提供了控制渲染编辑文本的方式的能力。上述函数通过使用 HTML 元素来渲染它,但它可以以任何其他方式渲染它,包括将其渲染到 <canvas> 元素中。

演示应用程序在必要时运行 render() 函数。这包括应用程序启动时运行一次,然后在用户输入文本时再次运行,方法是监听 textupdate 事件

js
// Listen to the EditContext's textupdate event.
// This tells us when text input happens. We use it to re-render the view.
editContext.addEventListener("textupdate", (e) => {
  render(editContext.text, e.selectionStart, e.selectionEnd);
});

// Do the initial render.
render(editContext.text, editContext.selectionStart, editContext.selectionEnd);

设置标记样式

如前面的 render() 函数代码示例所示,每个标记都赋予一个与标记类型相对应的类名。演示应用程序使用此类名来使用 CSS 设置标记样式,如下所示

css
.token-openTagStart,
.token-openTagEnd,
.token-closeTagStart,
.token-closeTagEnd,
.token-selfClose {
  background: rgb(7 53 92);
  margin: 0 2px;
  color: white;
  border-radius: 0.25rem;
}

.token-equal {
  color: white;
}

.token-tagName {
  font-weight: bold;
  color: rgb(117, 186, 242);
}

.token-attributeName {
  color: rgb(207, 81, 198);
}

.token-attributeValue {
  font-style: italic;
  color: rgb(127 230 127);
  border: 1px dashed #8c8c8c;
  border-width: 1px 0 1px 0;
}

.token-quoteStart,
.token-quoteEnd {
  font-weight: bold;
  color: rgb(127 230 127);
  border: 1px solid #8c8c8c;
  border-width: 1px 0 1px 1px;
  border-radius: 0.25rem 0 0 0.25rem;
}

.token-quoteEnd {
  border-width: 1px 1px 1px 0;
  border-radius: 0 0.25rem 0.25rem 0;
}

.token-text {
  color: #6a6a6a;
  padding: 0 0.25rem;
}

渲染选择

即使演示应用程序使用 <div> 元素作为编辑器,该元素已经支持显示闪烁的光标和突出显示用户选择,但 EditContext API 仍然需要渲染选择。这是因为 EditContext API 可用于不支持这些行为的其他类型的元素。自行渲染选择还可以让我们更好地控制选择如何显示。最后,因为 render() 函数每次运行时都会清除编辑器元素的 HTML 内容,所以用户可能做出的任何选择在下一次 render() 函数运行时都会丢失。

为了渲染选择,演示应用程序在 render() 函数的末尾使用了 Selection.setBaseAndExtent() 方法。要使用 setBaseAndExtent() 方法,我们需要一对 DOM 节点和字符偏移量来表示选择的开始和结束位置。但是,EditContext API 仅将当前选择的状态维护为一对开始和结束字符偏移量,这些偏移量位于整个编辑缓冲区中。演示应用程序代码使用另一个名为 fromOffsetsToSelection() 的函数将这些字符偏移量转换为四个值

  • 包含选择开始的 DOM 节点。
  • 表示选择开始在开始节点中的字符位置的数字。
  • 包含选择结束的 DOM 节点。
  • 表示选择结束在结束节点中的字符位置的数字。
js
function render(text, selectionStart, selectionEnd) {
  // ...
  // The beginning of the render function is omitted for brevity.

  // Convert the start/end offsets to a DOM selection.
  const { anchorNode, anchorOffset, extentNode, extentOffset } =
    fromOffsetsToSelection(selectionStart, selectionEnd);

  // Render the selection in the editor element.
  document
    .getSelection()
    .setBaseAndExtent(anchorNode, anchorOffset, extentNode, extentOffset);
}

您可以在 converter.js 文件中查看 fromOffsetsToSelection() 函数的代码。

更新控件边界

EditContext API 为我们提供了很大的灵活性来定义自己的文本编辑器 UI。但是,这也意味着我们需要处理一些通常由浏览器或操作系统 (OS) 处理的事情。

例如,我们必须告诉操作系统可编辑文本区域在页面上的位置。这样,操作系统就可以正确地定位用户可能正在使用其撰写文本的任何文本编辑 UI,例如 IME 组合窗口。

演示应用程序使用 EditContext.updateControlBounds() 方法,并为其提供一个 DOMRect 对象,该对象表示可编辑文本区域的边界。演示应用程序在编辑器初始化时以及窗口大小调整时都会调用此方法

js
function updateControlBounds() {
  // Get the DOMRect object for the editor element.
  const editorBounds = editorEl.getBoundingClientRect();

  // Update the control bounds of the EditContext instance.
  editContext.updateControlBounds(editorBounds);
}

// Call the updateControlBounds function when the editor is initialized,
updateControlBounds();

// And call it again when the window is resized.
window.addEventListener("resize", updateControlBounds);

处理 Tab、Enter 和其他文本编辑键

上一节中使用的 textupdate 事件在用户按下 TabEnter 键时不会触发,因此我们需要单独处理这些键。

为了处理它们,演示应用程序在编辑器元素上使用 keydown 事件的事件侦听器,并使用此侦听器更新 EditContext 实例的文本内容和选择,如下所示

js
// Handle key presses that are not already handled by the EditContext.
editorEl.addEventListener("keydown", (e) => {
  // EditContext.updateText() expects the start and end offsets
  // to be in the correct order, but the current selection state
  // might be backwards.
  const start = Math.min(editContext.selectionStart, editContext.selectionEnd);
  const end = Math.max(editContext.selectionStart, editContext.selectionEnd);

  // Handling the Tab key.
  if (e.key === "Tab") {
    // Prevent the default behavior of the Tab key.
    e.preventDefault();

    // Use the EditContext.updateText method to insert a tab character
    // at the current selection position.
    editContext.updateText(start, end, "\t");

    // Update the selection to be after the inserted tab character.
    updateSelection(start + 1, start + 1);

    // Re-render the editor.
    render(
      editContext.text,
      editContext.selectionStart,
      editContext.selectionEnd,
    );
  }

  // Handling the Enter key.
  if (e.key === "Enter") {
    // Use the EditContext.updateText method to insert a newline character
    // at the current selection position.
    editContext.updateText(start, end, "\n");

    // Update the selection to be after the inserted newline character.
    updateSelection(start + 1, start + 1);

    // Re-render the editor.
    render(
      editContext.text,
      editContext.selectionStart,
      editContext.selectionEnd,
    );
  }
});

以上代码还调用 updateSelection() 函数以在文本内容更新后更新选择。有关更多信息,请参阅下面的 更新选择状态和选择边界

我们可以通过处理其他键组合来改进代码,例如 Ctrl+CCtrl+V 来复制和粘贴文本,或者 Ctrl+ZCtrl+Y 来撤消和重做文本更改。

更新选择状态和选择边界

如前所述,render() 函数处理在编辑器元素中渲染当前用户选择。但演示应用程序还需要在用户更改选择时更新选择状态和边界。EditContext API 不会自动执行此操作,同样是因为编辑器 UI 的实现方式可能不同,例如使用 <canvas> 元素。

为了了解用户何时更改选择内容,演示应用程序使用了selectionchange事件和Document.getSelection()方法,这些方法提供了Selection对象,告知我们用户选择的位置。通过使用此信息,演示应用程序使用EditContext.updateSelection()EditContext.updateSelectionBounds()方法更新EditContext选择状态和选择范围。操作系统使用此信息来正确定位输入法组合窗口。

但是,由于EditContext API使用字符偏移量来表示选择内容,因此演示应用程序还使用了一个函数fromSelectionToOffsets()将DOM选择对象转换为字符偏移量。

js
// Listen to selectionchange events to let the
// EditContext know where it is.
document.addEventListener("selectionchange", () => {
  const selection = document.getSelection();

  // Convert the DOM selection into character offsets.
  const offsets = fromSelectionToOffsets(selection, editorEl);
  if (offsets) {
    updateSelection(offsets.start, offsets.end);
  }
});

// Update the selection and selection bounds in the EditContext object.
// This helps the OS position the IME composition window correctly.
function updateSelection(start, end) {
  editContext.updateSelection(start, end);
  // Get the bounds of the selection.
  editContext.updateSelectionBounds(
    document.getSelection().getRangeAt(0).getBoundingClientRect(),
  );
}

您可以在converter.js文件中查看fromSelectionToOffsets()函数的代码。

计算字符边界

除了使用EditContext.updateControlBounds()EditContext.updateSelectionBounds()方法帮助操作系统定位用户可能正在使用的文本编辑UI之外,操作系统还需要另一条信息:编辑器元素中某些字符的位置和大小。

为此,演示应用程序侦听characterboundsupdate事件,使用它来计算编辑器元素中某些字符的范围,然后使用EditContext.updateCharacterBounds()方法更新字符范围。

如前所述,EditContext API只知道字符偏移量,这意味着characterboundsupdate事件提供了它需要范围的字符的起始和结束偏移量。演示应用程序使用另一个函数fromOffsetsToRenderedTokenNodes()来查找这些字符已渲染到的DOM元素,并使用此信息来计算所需的范围。

js
// Listen to the characterboundsupdate event to know when character bounds
// information is needed, and which characters need bounds.
editContext.addEventListener("characterboundsupdate", (e) => {
  // Retrieve information about the token nodes in the range.
  const tokenNodes = fromOffsetsToRenderedTokenNodes(
    currentTokens,
    e.rangeStart,
    e.rangeEnd,
  );

  // Convert this information into a list of DOMRect objects.
  const charBounds = tokenNodes.map(({ node, nodeOffset, charOffset }) => {
    const range = document.createRange();
    range.setStart(node.firstChild, charOffset - nodeOffset);
    range.setEnd(node.firstChild, charOffset - nodeOffset + 1);
    return range.getBoundingClientRect();
  });

  // Let the EditContext instance know about the character bounds.
  editContext.updateCharacterBounds(e.rangeStart, charBounds);
});

您可以在converter.js文件中查看fromOffsetsToRenderedTokenNodes()函数的代码。

应用 IME 组合文本格式

演示应用程序完成了最后一步以完全支持输入法组合。当用户使用输入法组合文本时,输入法可能会决定要组合的文本的某些部分应以不同的格式显示以指示组合状态。例如,输入法可能会决定对文本进行下划线。

由于演示应用程序负责渲染可编辑文本区域中的内容,因此它也负责应用必要的输入法格式。演示应用程序通过侦听textformatupdate事件来了解输入法何时想要应用文本格式、应用位置以及应用哪些格式来实现此目的。

如以下代码片段所示,演示应用程序使用textformatupdate事件和fromOffsetsToSelection()函数再次查找输入法组合想要格式化的文本范围。

js
editContext.addEventListener("textformatupdate", (e) => {
  // Get the list of formats that the IME wants to apply.
  const formats = e.getTextFormats();

  for (const format of formats) {
    // Find the DOM selection that corresponds to the format's range.
    const selection = fromOffsetsToSelection(
      format.rangeStart,
      format.rangeEnd,
      editorEl,
    );

    // Highlight the selection with the right style and thickness.
    addHighlight(selection, format.underlineStyle, format.underlineThickness);
  }
});

上述事件处理程序调用名为addHighlight()的函数来格式化文本。此函数使用CSS自定义高亮API来渲染文本格式。CSS自定义高亮API提供了一种通过使用JavaScript创建范围和使用CSS对其进行样式设置来设置任意文本范围样式的机制。要使用此API,::highlight()伪元素用于定义高亮样式。

css
::highlight(ime-solid-thin) {
  text-decoration: underline 1px;
}

::highlight(ime-solid-thick) {
  text-decoration: underline 2px;
}

::highlight(ime-dotted-thin) {
  text-decoration: underline dotted 1px;
}

::highlight(ime-dotted-thick) {
  text-decoration: underline dotted 2px;
}

/* Other highlights are omitted for brevity. */

Highlight实例也会被创建、存储在对象中,并通过使用CSS.highlights属性注册到HighlightRegistry中。

js
// Instances of CSS custom Highlight objects, used to render
// the IME composition text formats.
const imeHighlights = {
  "solid-thin": null,
  "solid-thick": null,
  "dotted-thin": null,
  "dotted-thick": null,
  "dashed-thin": null,
  "dashed-thick": null,
  "wavy-thin": null,
  "wavy-thick": null,
  "squiggle-thin": null,
  "squiggle-thick": null,
};
for (const [key, value] of Object.entries(imeHighlights)) {
  imeHighlights[key] = new Highlight();
  CSS.highlights.set(`ime-${key}`, imeHighlights[key]);
}

有了这些,addHighlight()函数使用Range对象表示需要设置样式的范围,并将它们添加到Highlight对象中。

js
function addHighlight(selection, underlineStyle, underlineThickness) {
  // Get the right CSS custom Highlight object depending on the
  // underline style and thickness.
  const highlight =
    imeHighlights[
      `${underlineStyle.toLowerCase()}-${underlineThickness.toLowerCase()}`
    ];

  if (highlight) {
    // Add a range to the Highlight object.
    const range = document.createRange();
    range.setStart(selection.anchorNode, selection.anchorOffset);
    range.setEnd(selection.extentNode, selection.extentOffset);
    highlight.add(range);
  }
}

总结

本文向您展示了如何使用EditContext API构建一个支持输入法组合和语法高亮的简单HTML代码编辑器。

最终代码和实时演示可以在GitHub上找到:实时演示源代码

更重要的是,本文向您展示了EditContext API在编辑器的用户界面方面提供了很大的灵活性。基于此演示,您可以构建一个类似的文本编辑器,该编辑器使用<canvas>元素来渲染语法高亮的HTML代码,而不是演示中使用的<div>。您还可以更改每个标记的渲染方式或选择内容的渲染方式。

另请参阅