使用 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 以停用拼寫檢查。

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

.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, 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 物件。演示應用程式在編輯器初始化時以及視窗大小調整時呼叫此方法。

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 選擇狀態和選擇邊界。作業系統使用此資訊來正確放置 IME 組合視窗。

然而,由於 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 組合文字格式

演示應用程式完成了一個最終步驟以完全支援 IME 組合。當用戶使用 IME 組合文字時,IME 可能會決定組合文字的某些部分應該以不同的格式顯示以指示組合狀態。例如,IME 可能會決定對文字進行下劃線處理。

由於演示應用程式負責渲染可編輯文字區域中的內容,因此它也負責應用必要的 IME 格式。演示應用程式透過監聽 textformatupdate 事件來實現這一點,以瞭解 IME 何時、何地以及應用何種文字格式。

如以下程式碼片段所示,演示應用程式再次使用 textformatupdate 事件和 fromOffsetsToSelection() 函式來查詢 IME 組合要格式化的文字範圍。

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 程式碼編輯器,該編輯器支援 IME 組合和語法高亮。

最終程式碼和即時演示可在 GitHub 上找到:即時演示原始碼

更重要的是,本文向您展示了 EditContext API 在編輯器使用者介面方面提供了很大的靈活性。基於此演示,您可以構建一個類似的文字編輯器,它使用 <canvas> 元素而不是演示使用的 <div> 來渲染語法高亮的 HTML 程式碼。您還可以更改每個標記的渲染方式或選擇的渲染方式。

另見