使用 EditContext API
EditContext API 可用于构建支持高级文本输入体验的富文本编辑器,例如输入法编辑器 (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 以禁用拼写检查。
<div id="html-editor" spellcheck="false"></div>
为了样式化编辑器元素,使用了以下 CSS 代码。该代码使编辑器填充整个视口并在内容过多时滚动。还使用了 white-space 属性来保留 HTML 输入文本中的空白字符,并使用了 tab-size 属性使制表符渲染为两个空格。最后,设置了一些默认的背景、文本和插入符号颜色。
#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: black;
line-height: 1.6;
color: red;
}
使编辑器可编辑
要在网页上使元素可编辑,大多数情况下,你会使用 <input> 元素、<textarea> 元素或 contenteditable 属性。
然而,使用 EditContext API,你可以使其他类型的元素可编辑,而无需使用属性。要查看可与 EditContext API 一起使用的元素列表,请参阅 HTMLElement editContext 属性页上的可能的元素。
为了使编辑器可编辑,演示应用程序创建了一个 EditContext 实例,将一些初始 HTML 文本传递给构造函数,然后将编辑器元素的 editContext 属性设置为 EditContext 实例。
// 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 文件中
import { tokenizeHTML } from "./tokenizer.js";
渲染文本
每当调用 render() 函数时(即当用户输入文本或选择更改时),该函数会删除编辑器元素中的内容,然后将每个标记渲染为单独的 HTML 元素。
// 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 事件在用户输入文本时再次运行。
// 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 样式化标记,如下所示:
.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;
}
.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 节点。
- 一个数字,表示选择结束位置在结束节点内的字符位置。
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, editorEl);
// 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 对象。演示应用程序在编辑器初始化时以及窗口大小调整时调用此方法。
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 事件在用户按下 Tab 或 Enter 键时不会触发,因此我们需要单独处理这些键。
为了处理它们,演示应用程序使用编辑器元素上的 keydown 事件的事件监听器,并使用此监听器更新 EditContext 实例的文本内容和选择,如下所示:
// 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+C 和 Ctrl+V 用于复制和粘贴文本,或 Ctrl+Z 和 Ctrl+Y 用于撤消和重做文本更改。
更新选择状态和选择边界
如前所述,render() 函数负责在编辑器元素中渲染当前用户选择。但是演示应用程序还需要在用户更改选择时更新选择状态和边界。EditContext API 不会自动执行此操作,同样是因为编辑器 UI 可能以不同的方式实现,例如使用 <canvas> 元素。
为了知道用户何时更改选择,演示应用程序使用 selectionchange 事件和 Document.getSelection() 方法,它们提供一个 Selection 对象,告诉我们用户选择的位置。利用这些信息,演示应用程序使用 EditContext.updateSelection() 和 EditContext.updateSelectionBounds() 方法更新 EditContext 选择状态和选择边界。操作系统使用此信息来正确放置 IME 组合窗口。
然而,由于 EditContext API 使用字符偏移量来表示选择,演示应用程序还使用了一个函数 fromSelectionToOffsets(),将 DOM 选择对象转换为字符偏移量。
// 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 元素,并使用此信息来计算所需的边界。
// 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 组合文本格式
演示应用程序完成了一个最终步骤以完全支持 IME 组合。当用户使用 IME 组合文本时,IME 可能会决定组合文本的某些部分应该以不同的格式显示以指示组合状态。例如,IME 可能会决定对文本进行下划线处理。
由于演示应用程序负责渲染可编辑文本区域中的内容,因此它也负责应用必要的 IME 格式。演示应用程序通过监听 textformatupdate 事件来实现这一点,以了解 IME 何时、何地以及应用何种文本格式。
如以下代码片段所示,演示应用程序再次使用 textformatupdate 事件和 fromOffsetsToSelection() 函数来查找 IME 组合要格式化的文本范围。
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() 伪元素用于定义高亮样式。
::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 中。
// 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 对象中。
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 代码编辑器,该编辑器支持 IME 组合和语法高亮。
最终代码和实时演示可在 GitHub 上找到:实时演示和源代码。
更重要的是,本文向您展示了 EditContext API 在编辑器用户界面方面提供了很大的灵活性。基于此演示,您可以构建一个类似的文本编辑器,它使用 <canvas> 元素而不是演示使用的 <div> 来渲染语法高亮的 HTML 代码。您还可以更改每个标记的渲染方式或选择的渲染方式。