Verwendung der EditContext API

Die EditContext API kann verwendet werden, um auf dem Web reichhaltige Texteditoren zu erstellen, die erweiterte Texteingabeerfahrungen unterstützen, wie etwa Input Method Editor (IME)-Komposition, Emoji-Picker oder andere plattformspezifische Bearbeitungs-UI-Oberflächen.

Dieser Artikel behandelt die notwendigen Schritte, um einen Texteditor mit der EditContext API zu erstellen. In diesem Leitfaden überprüfen Sie die Hauptschritte, die beim Erstellen eines einfachen HTML-Code-Editors beteiligt sind, der den Code beim Eingeben der Syntax hervorhebt und die IME-Komposition unterstützt.

Abschließender Code und Live-Demo

Um den abschließenden Code zu sehen, schauen Sie sich den Quellcode auf GitHub an. Es ist eine gute Idee, den Quellcode beim Lesen offen zu halten, da das Tutorial nur die wichtigsten Teile des Codes zeigt.

Der Quellcode ist in die folgenden Dateien organisiert:

  • index.html enthält das Editor-UI-Element und lädt den notwendigen CSS- und JavaScript-Code für die Demo.
  • styles.css enthält die Stile für das Editor-UI.
  • editor.js enthält den JavaScript-Code, der das Editor-UI einrichtet, den HTML-Code rendert und Benutzereingaben verarbeitet.
  • tokenizer.js enthält den JavaScript-Code, der den HTML-Code in separate Token, wie öffnende Tags, schließende Tags und Textknoten, aufteilt.
  • converter.js enthält den JavaScript-Code, der zwischen den Zeichenversätzen, die die EditContext API verwendet, und den DOM-Knoten, die der Browser für Textauswahlen verwendet, konvertiert.

Um die Live-Demo zu nutzen, öffnen Sie Edit Context API: HTML Editor Demo in einem Browser, der die EditContext API unterstützt.

Erstellen des Editor-UI

Der erste Schritt ist die Erstellung der UI für den Editor. Der Editor ist ein <div>-Element mit dem spellcheck-Attribut, das auf false gesetzt ist, um die Rechtschreibprüfung zu deaktivieren:

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

Um das Editor-Element zu stylen, wird der folgende CSS-Code verwendet. Der Code sorgt dafür, dass der Editor den gesamten Viewport ausfüllt und scrollt, wenn zu viel Inhalt vorhanden ist, um hinein zu passen. Die white-space-Eigenschaft wird ebenfalls verwendet, um Leerzeichen, die im HTML-Eingabetext gefunden werden, zu erhalten, und die tab-size-Eigenschaft wird verwendet, um Tab-Zeichen als zwei Leerzeichen darzustellen. Schließlich werden einige Standard-Hintergrund-, Text- und Caret-Farben festgelegt:

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

Der Editor bearbeitbar machen

Um ein Element im Web bearbeitbar zu machen, verwenden Sie die meiste Zeit ein <input>-Element, ein <textarea>-Element oder das contenteditable-Attribut.

Mit der EditContext API können Sie jedoch andere Arten von Elementen bearbeitbar machen, ohne ein Attribut zu verwenden. Um die Liste der Elemente zu sehen, die mit der EditContext API verwendet werden können, siehe Mögliche Elemente auf der HTMLElement editContext-Eigenschaftsseite.

Um den Editor bearbeitbar zu machen, erstellt die Demo-App eine Instanz von EditContext, übergibt einige anfängliche HTML-Texte an den Konstruktor und setzt dann die editContext-Eigenschaft des Editor-Elements auf die EditContext-Instanz:

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;

Diese Codezeilen machen das Editor-Element fokussierbar. Das Eingeben von Text in das Element löst das textupdate-Ereignis auf der EditContext-Instanz aus.

Rendern des Textes und der Benutzerauswahl

Um den syntaxhervorgehobenen HTML-Code im Editor zu rendern, wenn der Benutzer Text eingibt, verwendet die Demo-App eine Funktion namens render(), die aufgerufen wird, wenn neuer Text eingegeben wird, Zeichen gelöscht werden oder wenn die Auswahl geändert wird.

Tokenisierung des HTML-Codes

Eines der ersten Dinge, die die render()-Funktion tut, ist das Tokenisieren der HTML-Textinhalte. Das Tokenisieren der HTML-Textinhalte ist notwendig, um die HTML-Syntax hervorzuheben, und umfasst das Lesen des HTML-Code-Strings und das Bestimmen, wo jedes öffnende Tag, schließende Tag, Attribut, Kommentarknoten und Textknoten beginnt und endet.

Die Demo-App verwendet die Funktion tokenizeHTML() dafür, die den String Zeichen für Zeichen durchläuft und dabei einen Zustandsautomaten beibehält. Sie können den Quellcode für die tokenizeHTML()-Funktion in tokenizer.js auf GitHub sehen.

Die Funktion wird in der HTML-Datei der Demo-App wie folgt importiert:

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

Rendern des Textes

Wann immer die render()-Funktion aufgerufen wird, also wenn der Benutzer Text eingibt oder wenn sich die Auswahl ändert, entfernt die Funktion den Inhalt im Editor-Element und rendert dann jedes Token als separates HTML-Element:

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.
  // ...
}

Die EditContext API gibt die Möglichkeit, die Art der Darstellung des bearbeiteten Textes zu steuern. Die obige Funktion rendert ihn, indem sie HTML-Elemente verwendet, aber sie könnte ihn auf jede andere Weise rendern, einschließlich des Renderns in ein <canvas>-Element.

Die Demo-App führt die render()-Funktion aus, wenn dies notwendig ist. Dies schließt einmal ein, wenn die App startet, und dann erneut, wenn der Benutzer Text eingibt, indem das textupdate-Ereignis überwacht wird:

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);

Stilierung der Tokens

Wie im vorherigen render()-Funktionscode-Beispiel zu sehen ist, erhält jedes Token einen Klassennamen, der dem Token-Typ entspricht. Die Demo-App verwendet diesen Klassennamen, um die Tokens zu stylen, indem sie CSS verwendet, wie unten gezeigt:

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

Rendern der Auswahl

Obwohl die Demo-App ein <div>-Element für den Editor verwendet, das bereits das Anzeigen eines blinkenden Textcursors und das Hervorheben von Benutzerauswahlen unterstützt, erfordert die EditContext API dennoch das Rendern der Auswahl. Dies liegt daran, dass die EditContext API mit anderen Arten von Elementen verwendet werden kann, die diese Verhaltensweisen nicht unterstützen. Das eigenständige Rendern der Auswahl gibt uns auch mehr Kontrolle darüber, wie die Auswahl angezeigt wird. Schließlich geht jede Auswahl, die der Benutzer eventuell getroffen hat, verloren, wenn die render()-Funktion ausgeführt wird, weil die render()-Funktion den HTML-Inhalt des Editor-Elements jedes Mal löscht, wenn sie läuft.

Um die Auswahl zu rendern, verwendet die Demo-App die Methode Selection.setBaseAndExtent() am Ende der render()-Funktion. Um die setBaseAndExtent()-Methode zu verwenden, benötigen wir ein Paar aus DOM-Knoten und Zeichenversätzen, die den Start und das Ende der Auswahl darstellen. Die EditContext API speichert den aktuellen Auswahlstatus jedoch nur als ein Paar von Start- und Endzeichenversätzen im gesamten Bearbeitungspuffer. Der Demo-App-Code verwendet eine andere Funktion namens fromOffsetsToSelection(), die diese Zeichenversätze in vier Werte umwandelt:

  • Der DOM-Knoten, der den Start der Auswahl enthält.
  • Eine Zahl, die die Zeichenposition des Auswahlstarts im Startknoten darstellt.
  • Der DOM-Knoten, der das Ende der Auswahl enthält.
  • Eine Zahl, die die Zeichenposition des Auswahlendepunkts im Endknoten darstellt.
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);
}

Den Code für die fromOffsetsToSelection()-Funktion können Sie in der Datei converter.js einsehen.

Aktualisieren der Kontrollgrenzen

Die EditContext API gibt uns viel Flexibilität, um unsere eigene Texteditor-UI zu definieren. Dies bedeutet jedoch auch, dass wir einige Dinge selbst handhaben müssen, die normalerweise vom Browser oder dem Betriebssystem (OS) behandelt werden.

Zum Beispiel müssen wir dem OS sagen, wo sich der bearbeitbare Textbereich auf der Seite befindet. Auf diese Weise kann das OS jede Textbearbeitungs-UI, mit der der Benutzer gerade Text eingibt, wie ein IME-Kompositionsfenster, korrekt positionieren.

Die Demo-App verwendet die Methode EditContext.updateControlBounds(), die ein DOMRect-Objekt erhält, das die Grenzen des bearbeitbaren Textbereichs darstellt. Die Demo-App ruft diese Methode auf, wenn der Editor initialisiert wird, und erneut, wenn das Fenster in der Größe verändert wird:

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);

Handhaben von Tab, Enter und anderen Textbearbeitungstasten

Das textupdate-Ereignis, das im vorherigen Abschnitt verwendet wurde, wird nicht ausgelöst, wenn der Benutzer die Tab- oder Enter-Tasten drückt, daher müssen wir diese Tasten separat behandeln.

Um sie zu handhaben, verwendet die Demo-App einen Event-Listener für das keydown-Ereignis auf dem Editor-Element und verwendet diesen Listener, um den Inhalt des EditContext-Instanztextes und die Auswahl zu aktualisieren, wie unten gezeigt:

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,
    );
  }
});

Der obige Code ruft auch die Funktion updateSelection() auf, um die Auswahl zu aktualisieren, nachdem der Textinhalt aktualisiert wurde. Siehe Aktualisieren des Auswahlstatus und der Auswahlgrenzen unten für weitere Informationen.

Wir könnten den Code verbessern, indem wir andere Tastenkombinationen handhaben, wie Ctrl+C und Ctrl+V, um Text zu kopieren und einzufügen, oder Ctrl+Z und Ctrl+Y, um Textänderungen rückgängig zu machen und zu wiederholen.

Aktualisieren des Auswahlstatus und der Auswahlgrenzen

Wie wir bereits gesehen haben, handhabt die render()-Funktion das Rendern der aktuellen Benutzerauswahl im Editor-Element. Aber die Demo-App muss auch den Auswahlstatus und die Auswahlgrenzen aktualisieren, wenn der Benutzer die Auswahl ändert. Die EditContext API tut dies nicht automatisch, wiederum weil die Editor-UI auf andere Weise umgesetzt werden könnte, wie zum Beispiel durch die Verwendung eines <canvas>-Elements.

Um zu wissen, wann der Benutzer die Auswahl ändert, verwendet die Demo-App das selectionchange-Ereignis und die Methode Document.getSelection(), die ein Selection-Objekt bereitstellen, das uns mitteilt, wo sich die Auswahl des Benutzers befindet. Mit diesen Informationen aktualisiert die Demo-App den Auswahlstatus und die Auswahlgrenzen der EditContext API, indem sie die Methoden EditContext.updateSelection() und EditContext.updateSelectionBounds() verwendet. Dies wird vom OS verwendet, um das IME-Kompositionsfenster korrekt zu positionieren.

Da die EditContext API jedoch Zeichenversätze verwendet, um die Auswahl darzustellen, verwendet die Demo-App auch eine Funktion, fromSelectionToOffsets(), die DOM-Auswahlobjekte in Zeichenversätze umwandelt.

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(),
  );
}

Den Code für die fromSelectionToOffsets()-Funktion können Sie in der Datei converter.js einsehen.

Berechnen der Zeichenbegrenzungen

Neben der Verwendung der Methoden EditContext.updateControlBounds() und EditContext.updateSelectionBounds(), um dem OS zu helfen, eine Textbearbeitungs-UI korrekt zu positionieren, die der Benutzer möglicherweise verwendet, gibt es noch ein weiteres Stück Information, das das OS benötigt: die Position und Größe bestimmter Zeichen innerhalb des Editor-Elements.

Um dies zu tun, hört die Demo-App auf das characterboundsupdate-Ereignis, verwendet es, um die Begrenzungen einiger Zeichen im Editor-Element zu berechnen, und verwendet dann die Methode EditContext.updateCharacterBounds(), um die Zeichenbegrenzungen zu aktualisieren.

Wie zuvor gesehen, kennt die EditContext API nur Zeichenversätze, was bedeutet, dass das characterboundsupdate-Ereignis die Start- und Endversätze für die Zeichen liefert, für die es Begrenzungen benötigt. Die Demo-App verwendet eine weitere Funktion, fromOffsetsToRenderedTokenNodes(), um die DOM-Elemente zu finden, in denen diese Zeichen gerendert wurden, und verwendet diese Informationen, um die erforderlichen Begrenzungen zu berechnen.

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);
});

Den Code für die fromOffsetsToRenderedTokenNodes()-Funktion können Sie in der Datei converter.js einsehen.

Anwenden von IME-Kompositionstextformaten

Die Demo-App durchläuft einen letzten Schritt, um die volle Unterstützung der IME-Komposition zu gewährleisten. Wenn der Benutzer Text mit einem IME komponiert, kann das IME entscheiden, dass bestimmte Teile des zusammengesetzten Textes anders formatiert werden sollten, um den Kompositionsstatus anzuzeigen. Zum Beispiel könnte das IME entscheiden, den Text zu unterstreichen.

Da es die Verantwortung der Demo-App ist, die Inhalte im bearbeitbaren Textbereich zu rendern, ist es auch ihre Verantwortung, das notwendige IME-Format anzuwenden. Die Demo-App erreicht dies, indem sie auf das textformatupdate-Ereignis hört, um zu erfahren, wann das IME Textformate anwenden möchte, wo und welche Formate anzuwenden sind.

Wie im folgenden Code-Ausschnitt gezeigt, verwendet die Demo-App das textformatupdate-Ereignis und die Funktion fromOffsetsToSelection() erneut, um den Textbereich zu finden, den die IME-Komposition formatieren möchte:

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);
  }
});

Der obige Ereignis-Handler ruft die Funktion namens addHighlight() auf, um Text zu formatieren. Diese Funktion verwendet die CSS Custom Highlight API, um die Textformate zu rendern. Die CSS Custom Highlight API bietet einen Mechanismus, um beliebige Textbereiche mit JavaScript zu erstellen und mit CSS zu stylen. Um diese API zu verwenden, wird das ::highlight()-Pseudo-Element verwendet, um die Highlight-Stile zu definieren:

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-Instanzen werden ebenfalls erstellt, in einem Objekt gespeichert und im HighlightRegistry registriert, indem die CSS.highlights-Eigenschaft verwendet wird:

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]);
}

Mit diesen Voraussetzungen verwendet die addHighlight()-Funktion Range-Objekte für die Bereiche, die gestylt werden müssen, und fügt sie dem Highlight-Objekt hinzu:

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);
  }
}

Zusammenfassung

Dieser Artikel hat Ihnen gezeigt, wie Sie die EditContext API verwenden, um einen einfachen HTML-Code-Editor zu erstellen, der die IME-Komposition und die Syntaxhervorhebung unterstützt.

Der endgültige Code und die Live-Demo sind auf GitHub zu finden: Live-Demo und Quellcode.

Noch wichtiger ist, dass dieser Artikel gezeigt hat, dass die EditContext API viel Flexibilität bietet, wenn es um die Benutzeroberfläche Ihres Editors geht. Basierend auf dieser Demo könnten Sie einen ähnlichen Texteditor erstellen, der ein <canvas>-Element verwendet, um den syntaxhervorgehobenen HTML-Code statt des <div>, das in der Demo verwendet wird, zu rendern. Sie könnten auch ändern, wie jedes Token gerendert wird oder wie die Auswahl gerendert wird.

Siehe auch