Erstellen eines Rich-Text-Editors (WYSIWYG)
Veröffentlicht: 2022-03-10In den letzten Jahren hat der Bereich der Inhaltserstellung und -darstellung auf digitalen Plattformen einen massiven Umbruch erlebt. Der weit verbreitete Erfolg von Produkten wie Quip, Google Docs und Dropbox Paper hat gezeigt, wie Unternehmen darum kämpfen, die beste Erfahrung für Inhaltsersteller im Unternehmensbereich zu schaffen, und versuchen, innovative Wege zu finden, um mit den traditionellen Mustern zu brechen, wie Inhalte geteilt und konsumiert werden. Unter Ausnutzung der massiven Reichweite von Social-Media-Plattformen gibt es eine neue Welle unabhängiger Inhaltsersteller, die Plattformen wie Medium nutzen, um Inhalte zu erstellen und mit ihrem Publikum zu teilen.
Da so viele Menschen mit unterschiedlichen Berufen und Hintergründen versuchen, Inhalte auf diesen Produkten zu erstellen, ist es wichtig, dass diese Produkte eine leistungsstarke und nahtlose Erfahrung der Inhaltserstellung bieten und über Teams von Designern und Ingenieuren verfügen, die im Laufe der Zeit ein gewisses Maß an Fachwissen in diesem Bereich entwickeln . Mit diesem Artikel versuchen wir, nicht nur den Grundstein für den Aufbau eines Editors zu legen, sondern den Lesern auch einen Einblick zu geben, wie kleine Nuggets von Funktionalitäten, wenn sie zusammengebracht werden, eine großartige Benutzererfahrung für einen Content-Ersteller schaffen können.
Die Dokumentstruktur verstehen
Bevor wir uns mit der Erstellung des Editors befassen, schauen wir uns an, wie ein Dokument für einen Rich-Text-Editor strukturiert ist und welche verschiedenen Arten von Datenstrukturen beteiligt sind.
Dokumentknoten
Dokumentknoten werden verwendet, um den Inhalt des Dokuments darzustellen. Die üblichen Arten von Knoten, die ein Rich-Text-Dokument enthalten könnte, sind Absätze, Überschriften, Bilder, Videos, Codeblöcke und Pull-Quotes. Einige davon können andere Knoten als untergeordnete Knoten enthalten (z. B. enthalten Absatzknoten Textknoten in sich). Knoten enthalten auch alle Eigenschaften, die für das Objekt, das sie darstellen, spezifisch sind und zum Rendern dieser Knoten im Editor benötigt werden. (z. B. Image-Knoten enthalten eine image src
-Eigenschaft, Code-Blöcke können eine language
-Eigenschaft enthalten und so weiter).
Es gibt im Wesentlichen zwei Arten von Knoten, die darstellen, wie sie gerendert werden sollten -
- Blockknoten (analog zum HTML-Konzept von Block-Level-Elementen), die jeweils auf einer neuen Zeile gerendert werden und die verfügbare Breite einnehmen. Blockknoten können andere Blockknoten oder Inline-Knoten in sich enthalten. Eine Beobachtung hier ist, dass die Knoten der obersten Ebene eines Dokuments immer Blockknoten wären.
- Inline-Knoten (analog zum HTML-Konzept von Inline-Elementen), die mit dem Rendern in derselben Zeile wie der vorherige Knoten beginnen. Es gibt einige Unterschiede in der Darstellung von Inline-Elementen in verschiedenen Bearbeitungsbibliotheken. SlateJS ermöglicht es, dass Inline-Elemente selbst Knoten sind. Mit DraftJS, einer weiteren beliebten Rich-Text-Editing-Bibliothek, können Sie das Konzept von Entitäten verwenden, um Inline-Elemente zu rendern. Links und Inline-Bilder sind Beispiele für Inline-Knoten.
- Leere Knoten – SlateJS lässt auch diese dritte Kategorie von Knoten zu, die wir später in diesem Artikel zum Rendern von Medien verwenden werden.
Wenn Sie mehr über diese Kategorien erfahren möchten, ist die Dokumentation von SlateJS zu Nodes ein guter Ausgangspunkt.
Attribute
Ähnlich dem Konzept von HTML-Attributen werden Attribute in einem Rich-Text-Dokument verwendet, um Nicht-Inhaltseigenschaften eines Knotens oder seiner Kinder darzustellen. Beispielsweise kann ein Textknoten Attribute im Zeichenstil haben, die uns mitteilen, ob der Text fett/kursiv/unterstrichen usw. ist. Obwohl dieser Artikel Überschriften selbst als Knoten darstellt, könnte eine andere Möglichkeit, sie darzustellen, darin bestehen, dass Knoten Absatzstile ( paragraph
& h1-h6
) als Attribute haben.
Das folgende Bild zeigt ein Beispiel dafür, wie die Struktur eines Dokuments (in JSON) auf einer detaillierteren Ebene beschrieben wird, indem Knoten und Attribute verwendet werden, die einige der Elemente in der Struktur auf der linken Seite hervorheben.
Einige der Dinge, die es wert sind, hier mit der Struktur genannt zu werden, sind:
- Textknoten werden dargestellt als
{text: 'text content'}
- Eigenschaften der Nodes werden direkt auf dem Node gespeichert (zB
url
für Links undcaption
für Bilder) - Die SlateJS-spezifische Darstellung von Textattributen macht die Textknoten zu eigenen Knoten, wenn sich der Zeichenstil ändert. Daher ist der Text „ Duis aute irure dolor “ ein eigener Textknoten, auf dem
bold: true
gesetzt ist. Dasselbe gilt für den kursiven, unterstrichenen und codierten Text in diesem Dokument.
Standorte und Auswahl
Beim Erstellen eines Rich-Text-Editors ist es wichtig, zu verstehen, wie der granularste Teil eines Dokuments (z. B. ein Zeichen) mit einer Art Koordinaten dargestellt werden kann. Dies hilft uns, zur Laufzeit durch die Dokumentstruktur zu navigieren, um zu verstehen, wo wir uns in der Dokumenthierarchie befinden. Am wichtigsten ist, dass Standortobjekte uns eine Möglichkeit bieten, die Benutzerauswahl darzustellen, die ziemlich häufig verwendet wird, um die Benutzererfahrung des Editors in Echtzeit anzupassen. Wir werden die Auswahl verwenden, um unsere Symbolleiste später in diesem Artikel zu erstellen. Beispiele hierfür könnten sein:
- Befindet sich der Cursor des Benutzers derzeit in einem Link, sollten wir ihm vielleicht ein Menü zum Bearbeiten/Entfernen des Links zeigen?
- Hat der Benutzer ein Bild ausgewählt? Vielleicht geben wir ihnen ein Menü, um die Größe des Bildes zu ändern.
- Wenn der Benutzer bestimmten Text auswählt und auf die Schaltfläche LÖSCHEN klickt, bestimmen wir, welcher Text vom Benutzer ausgewählt wurde, und entfernen ihn aus dem Dokument.
Das Dokument von SlateJS zu Location erklärt diese Datenstrukturen ausführlich, aber wir gehen sie hier schnell durch, da wir diese Begriffe an verschiedenen Stellen im Artikel verwenden und ein Beispiel im folgenden Diagramm zeigen.
- Weg
Ein Pfad wird durch eine Reihe von Zahlen dargestellt und ist der Weg, um zu einem Knoten im Dokument zu gelangen. Beispielsweise repräsentiert ein Pfad[2,3]
den 3. untergeordneten Knoten des 2. Knotens im Dokument. - Punkt
Detailliertere Position des Inhalts, dargestellt durch Pfad + Offset. Beispielsweise repräsentiert ein Punkt von{path: [2,3], offset: 14}
das 14. Zeichen des 3. untergeordneten Knotens innerhalb des 2. Knotens des Dokuments. - Bereich
Ein Punktpaar (anchor
undfocus
genannt), das einen Textbereich innerhalb des Dokuments darstellt. Dieses Konzept stammt von der Auswahl-API von Web, bei deranchor
dort ist, wo die Auswahl des Benutzers begann, undfocus
dort, wo sie endete. Ein zusammengeklappter Bereich/Auswahl zeigt an, wo Anker- und Fokuspunkte gleich sind (denken Sie beispielsweise an einen blinkenden Cursor in einer Texteingabe).
Nehmen wir als Beispiel an, dass die Auswahl des Benutzers in unserem obigen Dokumentbeispiel ipsum
ist:
Die Auswahl des Benutzers kann wie folgt dargestellt werden:
{ anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum' }`
Einrichten des Editors
In diesem Abschnitt werden wir die Anwendung einrichten und einen einfachen Rich-Text-Editor mit SlateJS zum Laufen bringen. Die Boilerplate-Anwendung wäre
mit hinzugefügten SlateJS-Abhängigkeiten. Wir erstellen die Benutzeroberfläche der Anwendung mit Komponenten aus create-react-app
. Lass uns anfangen!react-bootstrap
Erstellen Sie einen Ordner mit dem Namen wysiwyg-editor
und führen Sie den folgenden Befehl aus dem Verzeichnis heraus aus, um die React-App einzurichten. Wir führen dann einen yarn start
aus, der den lokalen Webserver (Port standardmäßig auf 3000) hochfahren und Ihnen einen React-Begrüßungsbildschirm anzeigen sollte.
npx create-react-app . yarn start
Anschließend fügen wir der Anwendung die SlateJS-Abhängigkeiten hinzu.
yarn add slate slate-react
slate
ist das Kernpaket von SlateJS und slate-react
enthält den Satz von React-Komponenten, die wir zum Rendern von Slate-Editoren verwenden werden. SlateJS stellt einige weitere Pakete bereit, die nach Funktionen organisiert sind, die man in Betracht ziehen könnte, ihrem Editor hinzuzufügen.
Wir erstellen zuerst einen utils
Ordner, der alle Utility-Module enthält, die wir in dieser Anwendung erstellen. Wir beginnen mit der Erstellung einer ExampleDocument.js
, die eine grundlegende Dokumentstruktur zurückgibt, die einen Absatz mit etwas Text enthält. Dieses Modul sieht wie folgt aus:
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
Wir fügen jetzt einen Ordner namens components
hinzu, der alle unsere React-Komponenten enthält, und tun Folgendes:
- Fügen Sie unsere erste React-Komponente
Editor.js
. Es gibt vorerst nur eindiv
zurück. - Aktualisieren Sie die
App.js
Komponente, um das Dokument in seinem Zustand zu halten, der mit unseremExampleDocument
oben initialisiert wurde. - Rendern Sie den Editor in der App und übergeben Sie den Dokumentstatus und einen
onChange
-Handler an den Editor, damit unser Dokumentstatus aktualisiert wird, wenn der Benutzer ihn aktualisiert. - Wir verwenden die Nav-Komponenten von React Bootstrap, um der Anwendung auch eine Navigationsleiste hinzuzufügen.
App.js
Komponente sieht jetzt wie folgt aus:
import Editor from './components/Editor'; function App() { const [document, updateDocument] = useState(ExampleDocument); return ( <> <Navbar bg="dark" variant="dark"> <Navbar.Brand href="#"> <img alt="" src="/app-icon.png" width="30" height="30" className="d-inline-block align-top" />{" "} WYSIWYG Editor </Navbar.Brand> </Navbar> <div className="App"> <Editor document={document} onChange={updateDocument} /> </div> </> );
Innerhalb der Editor-Komponente instanziieren wir dann den SlateJS-Editor und speichern ihn in einem useMemo
, damit sich das Objekt zwischen den erneuten Renderings nicht ändert.
// dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);
createEditor
gibt uns die SlateJS- editor
, die wir in der Anwendung ausgiebig verwenden, um auf Auswahlen zuzugreifen, Datentransformationen auszuführen und so weiter. withReact ist ein SlateJS-Plugin, das dem Editor-Objekt React- und DOM-Verhalten hinzufügt. SlateJS-Plug-ins sind Javascript-Funktionen, die das editor
-Objekt empfangen und ihm einige Konfigurationen hinzufügen. Auf diese Weise können Webentwickler ihrer SlateJS-Editorinstanz auf zusammensetzbare Weise Konfigurationen hinzufügen.
Wir importieren und rendern jetzt <Slate />
- und <Editable />
Komponenten aus SlateJS mit dem Dokumentprop, das wir von App.js erhalten. Slate
legt eine Reihe von React-Kontexten offen, die wir für den Zugriff im Anwendungscode verwenden. Editable
ist die Komponente, die die Dokumenthierarchie zur Bearbeitung rendert. Insgesamt sieht das
-Modul zu diesem Zeitpunkt wie folgt aus:Editor.js
import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }
An dieser Stelle haben wir die erforderlichen React-Komponenten hinzugefügt und den Editor mit einem Beispieldokument gefüllt. Unser Editor sollte jetzt so eingerichtet sein, dass wir den Inhalt in Echtzeit eingeben und ändern können – wie im folgenden Screencast.
Fahren wir nun mit dem nächsten Abschnitt fort, in dem wir den Editor konfigurieren, um Zeichenstile und Absatzknoten zu rendern.
BENUTZERDEFINIERTE TEXT-WIEDERGABE UND EINE SYMBOLLEISTE
Absatzstilknoten
Derzeit verwendet unser Editor das Standard-Rendering von SlateJS für alle neuen Knotentypen, die wir dem Dokument hinzufügen können. In diesem Abschnitt möchten wir in der Lage sein, die Überschriftenknoten zu rendern. Um dies tun zu können, stellen wir eine renderElement
Funktionsstütze für die Komponenten von Slate bereit. Diese Funktion wird von Slate zur Laufzeit aufgerufen, wenn es versucht, den Dokumentbaum zu durchlaufen und jeden Knoten zu rendern. Die Funktion renderElement bekommt drei Parameter —
-
attributes
SlateJS-spezifisch, die auf das DOM-Element der obersten Ebene angewendet werden müssen, das von dieser Funktion zurückgegeben wird. -
element
Das Knotenobjekt selbst, wie es in der Dokumentstruktur vorhanden ist -
children
Die Kinder dieses Knotens, wie in der Dokumentstruktur definiert.
Wir fügen unsere renderElement
Implementierung zu einem Hook namens useEditorConfig
, wo wir im Laufe der Zeit weitere Editor-Konfigurationen hinzufügen werden. Wir verwenden dann den Hook auf der Editor-Instanz in Editor.js
.
import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }
Da uns diese Funktion Zugriff auf das element
gibt (das der Knoten selbst ist), können wir renderElement
anpassen, um ein angepassteres Rendering zu implementieren, das mehr als nur element.type
überprüft. Beispielsweise könnten Sie einen Bildknoten mit einer isInline
Eigenschaft haben, die wir verwenden könnten, um eine andere DOM-Struktur zurückzugeben, die uns hilft, Inline-Bilder im Vergleich zu Blockbildern zu rendern.
Wir aktualisieren jetzt die Editor-Komponente, um diesen Hook wie folgt zu verwenden:
const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );
Nachdem das benutzerdefinierte Rendering vorhanden ist, aktualisieren wir das ExampleDocument, um unsere neuen Knotentypen aufzunehmen, und überprüfen, ob sie im Editor korrekt gerendert werden.
const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes
Zeichenstile
Ähnlich wie renderElement
gibt SlateJS eine Funktionsstütze namens renderLeaf aus, die verwendet werden kann, um das Rendering der Textknoten anzupassen ( Leaf
bezieht sich auf Textknoten, die die Blätter/Knoten der untersten Ebene des Dokumentbaums sind). Nach dem Beispiel von renderElement
schreiben wir eine Implementierung für renderLeaf
.
export default function useEditorConfig(editor) { return { renderElement, renderLeaf }; } // ... function renderLeaf({ attributes, children, leaf }) { let el = <>{children}</>; if (leaf.bold) { el = <strong>{el}</strong>; } if (leaf.code) { el = <code>{el}</code>; } if (leaf.italic) { el = <em>{el}</em>; } if (leaf.underline) { el = <u>{el}</u>; } return <span {...attributes}>{el}</span>; }
Eine wichtige Beobachtung der obigen Implementierung ist, dass sie es uns ermöglicht, die HTML-Semantik für Zeichenstile zu respektieren. Da renderLeaf uns Zugriff auf das leaf
selbst gibt, können wir die Funktion anpassen, um ein angepassteres Rendering zu implementieren. Sie könnten beispielsweise eine Möglichkeit haben, Benutzern die Auswahl einer highlightColor
für Text zu ermöglichen und diese Blatteigenschaft hier zu aktivieren, um die entsprechenden Stile hinzuzufügen.
Wir aktualisieren jetzt die Editor-Komponente, um das Obige zu verwenden, das ExampleDocument
, um einige Textknoten im Absatz mit Kombinationen dieser Stile zu haben, und überprüfen, ob sie wie erwartet im Editor mit den von uns verwendeten semantischen Tags gerendert werden.
# src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
# src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], },
Hinzufügen einer Symbolleiste
Beginnen wir mit dem Hinzufügen einer neuen Komponente Toolbar.js
, der wir einige Schaltflächen für Zeichenstile und ein Dropdown-Menü für Absatzstile hinzufügen und die wir später in diesem Abschnitt verdrahten.
const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"]; const CHARACTER_STYLES = ["bold", "italic", "underline", "code"]; export default function Toolbar({ selection, previousSelection }) { return ( <div className="toolbar"> {/* Dropdown for paragraph styles */} <DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={false} /> ))} </div> ); } function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( <Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} > {icon} </Button> ); }
Wir abstrahieren die Schaltflächen zur ToolbarButton
Komponente, die ein Wrapper um die React Bootstrap Button-Komponente ist. Anschließend rendern wir die Symbolleiste über der im Editor
Editable
Komponente und überprüfen, ob die Symbolleiste in der Anwendung angezeigt wird.
Hier sind die drei wichtigsten Funktionen, die die Symbolleiste unterstützen muss:
- Wenn sich der Cursor des Benutzers an einer bestimmten Stelle im Dokument befindet und er auf eine der Schaltflächen für den Zeichenstil klickt, müssen wir den Stil für den Text, den er als Nächstes eingeben kann, umschalten.
- Wenn der Benutzer einen Textbereich auswählt und auf eine der Schaltflächen für den Zeichenstil klickt, müssen wir den Stil für diesen bestimmten Abschnitt umschalten.
- Wenn der Benutzer einen Textbereich auswählt, möchten wir das Absatzstil-Dropdown aktualisieren, um den Absatztyp der Auswahl widerzuspiegeln. Wenn sie einen anderen Wert aus der Auswahl auswählen, möchten wir den Absatzstil der gesamten Auswahl so aktualisieren, dass er dem entspricht, was sie ausgewählt haben.
Schauen wir uns an, wie diese Funktionalitäten im Editor funktionieren, bevor wir mit der Implementierung beginnen.
Auswahl anhören
Das Wichtigste, was die Symbolleiste benötigt, um die oben genannten Funktionen ausführen zu können, ist der Auswahlstatus des Dokuments. Zum Zeitpunkt des Schreibens dieses Artikels stellt SlateJS keine onSelectionChange
Methode bereit, die uns den neuesten Auswahlstatus des Dokuments liefern könnte. Wenn sich jedoch die Auswahl im Editor ändert, ruft SlateJS die onChange
Methode auf, selbst wenn sich der Inhalt des Dokuments nicht geändert hat. Wir verwenden dies, um über eine Auswahländerung benachrichtigt zu werden, und speichern sie im Status der Editor
-Komponente. Wir abstrahieren dies zu einem Hook useSelection
, wo wir eine optimalere Aktualisierung des Auswahlzustands vornehmen könnten. Dies ist wichtig, da die Auswahl eine Eigenschaft ist, die sich für eine WYSIWYG-Editor-Instanz ziemlich oft ändert.
import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }
Wir verwenden diesen Hook wie unten in der Editor
-Komponente und übergeben die Auswahl an die Toolbar-Komponente.
const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...
Leistungsbetrachtung
In einer Anwendung, in der wir eine viel größere Editor-Codebasis mit viel mehr Funktionalitäten haben, ist es wichtig, Auswahländerungen auf performante Weise zu speichern und abzuhören (wie die Verwendung einer Zustandsverwaltungsbibliothek), da Komponenten, die Auswahländerungen überwachen, wahrscheinlich auch gerendert werden häufig. Eine Möglichkeit, dies zu tun, besteht darin, optimierte Selektoren über dem Auswahlstatus zu haben, die bestimmte Auswahlinformationen enthalten. Beispielsweise möchte ein Redakteur möglicherweise ein Bildgrößenänderungsmenü rendern, wenn ein Bild ausgewählt ist. In einem solchen Fall kann es hilfreich sein, wenn ein Selektor isImageSelected
aus dem Auswahlstatus des Editors berechnet wird und das Bildmenü nur dann neu gerendert wird, wenn sich der Wert dieses Selektors ändert. Reselect von Redux ist eine solche Bibliothek, die das Erstellen von Selektoren ermöglicht.
Wir verwenden die selection
innerhalb der Symbolleiste erst später, aber wenn Sie sie als Requisite weitergeben, wird die Symbolleiste jedes Mal neu gerendert, wenn sich die Auswahl im Editor ändert. Wir tun dies, weil wir uns nicht ausschließlich auf die Änderung des Dokumentinhalts verlassen können, um ein erneutes Rendern in der Hierarchie ( App -> Editor -> Toolbar
) auszulösen, da Benutzer möglicherweise einfach weiter um das Dokument herum klicken und dadurch die Auswahl ändern, aber nie den Dokumentinhalt ändern selbst.
Zeichenstile umschalten
Wir gehen jetzt dazu über, die aktiven Zeichenstile von SlateJS abzurufen und diese im Editor zu verwenden. Lassen Sie uns ein neues JS-Modul EditorUtils
, das alle util-Funktionen hostet, die wir in Zukunft erstellen, um Dinge mit SlateJS zu erhalten/zu erledigen. Unsere erste Funktion im Modul ist getActiveStyles
, die einen Set
aktiver Stile im Editor liefert. Wir fügen auch eine Funktion hinzu, um einen Stil in der Editor-Funktion umzuschalten — toggleStyle
:
# src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }
Beide Funktionen nehmen das editor
-Objekt, das die Slate-Instanz ist, als Parameter, ebenso wie viele util-Funktionen, die wir später in diesem Artikel hinzufügen und entfernen Sie diese Markierungen. Wir importieren diese util-Funktionen in die Symbolleiste und verbinden sie mit den zuvor hinzugefügten Schaltflächen.
# src/components/Toolbar.js import { getActiveStyles, toggleStyle } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; export default function Toolbar({ selection }) { const editor = useEditor(); return <div ... {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} characterStyle={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) => { event.preventDefault(); toggleStyle(editor, style); }} /> ))} </div>
useEditor
ist ein Slate-Hook, der uns Zugriff auf die Slate-Instanz aus dem Kontext gibt, in dem sie von der <Slate>
-Komponente weiter oben in der Renderhierarchie angehängt wurde.
Man könnte sich fragen, warum wir hier onMouseDown
statt onClick
? Es gibt ein offenes Github-Problem darüber, wie Slate die selection
auf null
setzt, wenn der Editor in irgendeiner Weise den Fokus verliert. Wenn wir onClick
Handler an unsere Symbolleistenschaltflächen anhängen, wird die selection
null
und Benutzer verlieren ihre Cursorposition, wenn sie versuchen, einen Stil umzuschalten, was keine großartige Erfahrung ist. Stattdessen schalten wir den Stil um, indem wir ein onMouseDown
Ereignis anhängen, das verhindert, dass die Auswahl zurückgesetzt wird. Eine andere Möglichkeit, dies zu tun, besteht darin, die Auswahl selbst zu verfolgen, damit wir wissen, was die letzte Auswahl war, und diese verwenden, um die Stile umzuschalten. Wir führen das Konzept von previousSelection
später in diesem Artikel ein, aber um ein anderes Problem zu lösen.
SlateJS ermöglicht es uns, Event-Handler im Editor zu konfigurieren. Wir verwenden das, um Tastaturkürzel zu verdrahten, um die Zeichenstile umzuschalten. Dazu fügen wir ein KeyBindings
Objekt innerhalb useEditorConfig
, wo wir einen onKeyDown
Ereignishandler verfügbar machen, der an die Editable
Komponente angehängt ist. Wir verwenden das is-hotkey
hotkey util, um die Tastenkombination zu ermitteln und den entsprechenden Stil umzuschalten.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} />
Absatzstil-Dropdown-Funktion zum Laufen bringen
Lassen Sie uns damit fortfahren, das Dropdown-Menü „Absatzstile“ zum Laufen zu bringen. Ähnlich wie Dropdown-Listen im Absatzstil in gängigen Textverarbeitungsanwendungen wie MS Word oder Google Docs funktionieren, möchten wir, dass die Stile der Blöcke der obersten Ebene in der Auswahl des Benutzers in der Dropdown-Liste wiedergegeben werden. Wenn in der Auswahl ein einziger konsistenter Stil vorhanden ist, aktualisieren wir den Dropdown-Wert auf diesen. Wenn es mehrere davon gibt, setzen wir den Dropdown-Wert auf „Mehrere“. Dieses Verhalten muss sowohl für reduzierte als auch für erweiterte Auswahlen funktionieren.
Um dieses Verhalten zu implementieren, müssen wir in der Lage sein, die Blöcke der obersten Ebene zu finden, die sich über die Auswahl des Benutzers erstrecken. Dazu verwenden wir Editor.nodes
von Slate – Eine Hilfsfunktion, die häufig verwendet wird, um nach Knoten in einem Baum zu suchen, der nach verschiedenen Optionen gefiltert wird.
nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>
Die Hilfsfunktion benötigt eine Editor-Instanz und ein options
, mit dem Knoten im Baum gefiltert werden können, während er ihn durchläuft. Die Funktion gibt einen Generator von NodeEntry
. Ein NodeEntry
in der Slate-Terminologie ist ein Tupel aus einem Knoten und dem Pfad zu ihm — [node, pathToNode]
. Die hier gefundenen Optionen sind für die meisten Slate-Hilfsfunktionen verfügbar. Lassen Sie uns durchgehen, was jede dieser Bedeutungen bedeutet:
-
at
Dies kann ein Pfad/Punkt/Bereich sein, den die Hilfsfunktion verwenden würde, um den Baum nach unten zu durchqueren. Dies ist standardmäßigeditor.selection
, falls nicht angegeben. Wir verwenden auch den Standardwert für unseren Anwendungsfall unten, da wir an Knoten innerhalb der Auswahl des Benutzers interessiert sind. -
match
Dies ist eine Matching-Funktion, die man bereitstellen kann, die auf jedem Knoten aufgerufen und bei Übereinstimmung eingeschlossen wird. Wir verwenden diesen Parameter in unserer Implementierung unten, um nur nach Blockelementen zu filtern. -
mode
Teilen Sie denmatch
mit, ob wir an allen Knoten der höchsten oder niedrigsten Ebeneat
der angegebenen Positionsübereinstimmungsfunktion interessiert sind. Dieser Parameter (auf maximum gesetzt) hilft uns, dem Versuch zu entgehen, den Baum selbst nach oben zu durchqueren, um die Knoten derhighest
Ebene zu finden. -
universal
Flag, um zwischen vollständigen oder teilweisen Übereinstimmungen der Knoten zu wählen. (GitHub Issue mit dem Vorschlag für dieses Flag hat einige Beispiele, die es erklären) -
reverse
Wenn die Knotensuche in umgekehrter Richtung der Start- und Endpunkte des übergebenen Ortes erfolgen soll. -
voids
Wenn die Suche nur nach ungültigen Elementen filtern soll.
SlateJS stellt viele Hilfsfunktionen bereit, mit denen Sie Knoten auf unterschiedliche Weise abfragen, den Baum durchlaufen und die Knoten oder Auswahlen auf komplexe Weise aktualisieren können. Es lohnt sich, sich mit einigen dieser Schnittstellen (die am Ende dieses Artikels aufgeführt sind) zu befassen, wenn Sie komplexe Bearbeitungsfunktionen auf Slate aufbauen.
Vor diesem Hintergrund der Hilfsfunktion finden Sie unten eine Implementierung von getTextBlockStyle
.
# src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }
Leistungsbetrachtung
Die aktuelle Implementierung von Editor.nodes
findet alle Knoten im gesamten Baum auf allen Ebenen, die sich im Bereich des at
-Parameters befinden, und führt dann Match-Filter darauf aus (überprüfen nodeEntries
und die Filterung später – Quelle). Für kleinere Dokumente ist dies in Ordnung. Wenn der Benutzer jedoch für unseren Anwendungsfall beispielsweise 3 Überschriften und 2 Absätze ausgewählt hat (wobei jeder Absatz beispielsweise 10 Textknoten enthält), durchläuft er mindestens 25 Knoten (3 + 2 + 2*10) und versucht, Filter auszuführen auf sie. Da wir bereits wissen, dass wir nur an Knoten der obersten Ebene interessiert sind, könnten wir Start- und Endindizes der Blöcke der obersten Ebene aus der Auswahl finden und selbst iterieren. Eine solche Logik würde nur 3 Knoteneinträge durchlaufen (2 Überschriften und 1 Absatz). Der Code dafür würde in etwa so aussehen:
export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex <= endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType; }
Da wir einem WYSIWYG-Editor weitere Funktionalitäten hinzufügen und den Dokumentenbaum häufig durchlaufen müssen, ist es wichtig, über die leistungsfähigsten Möglichkeiten nachzudenken, dies für den vorliegenden Anwendungsfall zu tun, da die verfügbaren API- oder Hilfsmethoden möglicherweise nicht immer die besten sind effiziente Art und Weise, dies zu tun.
Sobald wir getTextBlockStyle
implementiert haben, ist das Umschalten des Blockstils relativ einfach. Wenn der aktuelle Stil nicht dem entspricht, den der Benutzer in der Dropdown-Liste ausgewählt hat, schalten wir den Stil darauf um. Wenn es bereits das ist, was der Benutzer ausgewählt hat, schalten wir es in einen Absatz um. Da wir Absatzstile als Knoten in unserer Dokumentstruktur darstellen, bedeutet das Umschalten eines Absatzstils im Wesentlichen das Ändern der type
Eigenschaft des Knotens. Wir verwenden Transforms.setNodes
, das von Slate bereitgestellt wird, um Eigenschaften auf Knoten zu aktualisieren.
Die Implementierung unseres toggleBlockType
wie folgt aus:
# src/utils/EditorUtils.js export function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) => Editor.isBlock(editor, n) } ); }
Schließlich aktualisieren wir unser Absatzstil-Dropdown, um diese Hilfsfunktionen zu verwenden.
#src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... );
LINKS
In diesem Abschnitt werden wir Unterstützung zum Anzeigen, Hinzufügen, Entfernen und Ändern von Links hinzufügen. Wir werden auch eine Link-Detector-Funktion hinzufügen – ganz ähnlich wie Google Docs oder MS Word, die den vom Benutzer eingegebenen Text scannen und prüfen, ob dort Links vorhanden sind. Wenn dies der Fall ist, werden sie in Link-Objekte konvertiert, sodass der Benutzer die Schaltflächen der Symbolleiste nicht verwenden muss, um dies selbst zu tun.
Rendern von Links
In unserem Editor werden wir Links als Inline-Knoten mit SlateJS implementieren. Wir aktualisieren unsere Editor-Konfiguration, um Links als Inline-Knoten für SlateJS zu kennzeichnen, und stellen auch eine Komponente zum Rendern bereit, damit Slate weiß, wie die Link-Knoten gerendert werden.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
# src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }
We then add a link node to our ExampleDocument
and verify that it renders correctly (including a case for character styles inside a link) in the Editor.
# src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... }
Adding A Link Button To The Toolbar
Let's add a Link Button to the toolbar that enables the user to do the following:
- Selecting some text and clicking on the button converts that text into a link
- Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
- If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.
To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above
helper function from SlateJS.
# src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }
Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.
# src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> );
To toggle links in the editor, we add a util function toggleLinkAtSelection
. Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:
If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes
that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes
which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection
has the below implementation to insert a new link at an expanded selection.
# src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }
If the selection is collapsed, we insert a new node there with
that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.Transform.insertNodes
# src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} />
Link Editor Menu
So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor
component and rendering it whenever the user selection is inside a link.
# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
# src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>
Da wir den LinkEditor
außerhalb des Editors rendern, brauchen wir eine Möglichkeit, LinkEditor
mitzuteilen, wo sich der Link im DOM-Baum befindet, damit er sich selbst in der Nähe des Editors rendern kann. Dazu verwenden wir die React-API von Slate, um den DOM-Knoten zu finden, der dem ausgewählten Link-Knoten entspricht. Und wir verwenden dann getBoundingClientRect()
, um die Grenzen des DOM-Elements des Links und die Grenzen der Editor-Komponente zu finden und die top
und left
für den Link-Editor zu berechnen. Die Code-Updates für Editor
und LinkEditor
sind wie folgt:
# src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
# src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }
SlateJS verwaltet intern Zuordnungen von Knoten zu ihren jeweiligen DOM-Elementen. Wir greifen auf diese Karte zu und finden das DOM-Element des Links mit ReactEditor.toDOMNode
.
Wie im obigen Video zu sehen ist, wird, wenn ein Link eingefügt wird und keine URL hat, der Linkeditor geöffnet, da sich die Auswahl innerhalb des Links befindet, wodurch der Benutzer eine Möglichkeit erhält, eine URL für den neu eingefügten Link einzugeben und Damit schließt sich der Kreis zur Benutzererfahrung dort.
Wir fügen dem LinkEditor
jetzt ein Eingabeelement und eine Schaltfläche hinzu, mit denen der Benutzer eine URL eingeben und auf den Link-Knoten anwenden kann. Wir verwenden das isUrl
-Paket für die URL-Validierung.
# src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );
Wenn die Formularelemente verbunden sind, sehen wir uns an, ob der Link-Editor wie erwartet funktioniert.
Wie wir hier im Video sehen, verschwindet der Link-Editor, wenn der Benutzer versucht, in die Eingabe zu klicken. Dies liegt daran, dass Editable
beim Rendern des Link-Editors außerhalb der bearbeitbaren Komponente denkt, dass der Editor den Fokus verloren hat, wenn der Benutzer auf das Eingabeelement klickt, und die selection
auf null
zurücksetzt, wodurch der LinkEditor
entfernt wird, da isLinkActiveAtSelection
nicht mehr true
ist. Es gibt ein offenes GitHub-Problem, das sich mit diesem Slate-Verhalten befasst. Eine Möglichkeit, dies zu lösen, besteht darin, die vorherige Auswahl eines Benutzers zu verfolgen, während sie sich ändert, und wenn der Editor den Fokus verliert, könnten wir uns die vorherige Auswahl ansehen und trotzdem ein Link-Editor-Menü anzeigen, wenn die vorherige Auswahl einen Link enthielt. Lassen Sie uns den useSelection
-Hook aktualisieren, um sich an die vorherige Auswahl zu erinnern und diese an die Editor-Komponente zurückzugeben.
# src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }
Wir aktualisieren dann die Logik in der Editor
-Komponente, um das Link-Menü anzuzeigen, selbst wenn die vorherige Auswahl einen Link enthielt.
# src/components/Editor.js const [previousSelection, selection, setSelection] = useSelection(editor); let selectionForLink = null; if (isLinkNodeAtSelection(editor, selection)) { selectionForLink = selection; } else if (selection == null && isLinkNodeAtSelection(editor, previousSelection)) { selectionForLink = previousSelection; } return ( ... <div className="editor" ref={editorRef}> {selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );
Wir aktualisieren dann den LinkEditor
, um selectionForLink
zu verwenden, um den Link-Knoten zu suchen, darunter zu rendern und seine URL zu aktualisieren.
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
Links im Text erkennen
Die meisten Textverarbeitungsanwendungen identifizieren und konvertieren Links innerhalb von Text, um Objekte zu verknüpfen. Mal sehen, wie das im Editor funktionieren würde, bevor wir mit dem Bau beginnen.
Die Schritte der Logik zum Aktivieren dieses Verhaltens wären:
- Wenn sich das Dokument mit der Benutzereingabe ändert, finden Sie das letzte vom Benutzer eingefügte Zeichen. Wenn dieses Zeichen ein Leerzeichen ist, wissen wir, dass ein Wort davor stehen könnte.
- Wenn das letzte Zeichen ein Leerzeichen war, markieren wir das als Endgrenze des Wortes, das davor stand. Wir gehen dann Zeichen für Zeichen innerhalb des Textknotens zurück, um herauszufinden, wo dieses Wort begann. Während dieser Traversierung müssen wir darauf achten, nicht über die Kante des Knotenanfangs hinaus in den vorherigen Knoten zu gehen.
- Sobald wir die Anfangs- und Endgrenzen des Wortes zuvor gefunden haben, überprüfen wir die Zeichenfolge des Wortes und sehen, ob es sich um eine URL handelt. Wenn ja, wandeln wir ihn in einen Link-Knoten um.
Unsere Logik lebt in einer util-Funktion IdentifyLinksInTextIfAny, die in identifyLinksInTextIfAny
lebt und innerhalb der onChange
in Editor
-Komponente EditorUtils
wird.
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
Hier ist identifyLinksInTextIfAny
mit der Logik für Schritt 1 implementiert:
export function identifyLinksInTextIfAny(editor) { // if selection is not collapsed, we do not proceed with the link // detection if (editor.selection == null || !Range.isCollapsed(editor.selection)) { return; } const [node, _] = Editor.parent(editor, editor.selection); // if we are already inside a link, exit early. if (node.type === "link") { return; } const [currentNode, currentNodePath] = Editor.node(editor, editor.selection); // if we are not inside a text node, exit early. if (!Text.isText(currentNode)) { return; } let [start] = Range.edges(editor.selection); const cursorPoint = start; const startPointOfLastCharacter = Editor.before(editor, editor.selection, { unit: "character", }); const lastCharacter = Editor.string( editor, Editor.range(editor, startPointOfLastCharacter, cursorPoint) ); if(lastCharacter !== ' ') { return; }
Es gibt zwei SlateJS-Hilfsfunktionen, die die Arbeit hier vereinfachen.
-
Editor.before
— Gibt uns den Punkt vor einem bestimmten Ort. Es nimmt dieunit
als Parameter, damit wir nach dem Zeichen/Wort/Block usw. fragen können, bevor derlocation
übergeben wird. -
Editor.string
— Ruft die Zeichenfolge innerhalb eines Bereichs ab.
Als Beispiel erläutert das folgende Diagramm, welche Werte diese Variablen haben, wenn der Benutzer das Zeichen „E“ einfügt und sein Cursor danach sitzt.
Wenn der Text „ABCDE“ der erste Textknoten des ersten Absatzes im Dokument wäre, wären unsere Punktwerte –
cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}
Wenn das letzte Zeichen ein Leerzeichen war, wissen wir, wo es angefangen hat – startPointOfLastCharacter.
Gehen wir zu Schritt 2 über, wo wir Zeichen für Zeichen rückwärts gehen, bis wir entweder ein anderes Leerzeichen oder den Anfang des Textknotens selbst finden.
... if (lastCharacter !== " ") { return; } let end = startPointOfLastCharacter; start = Editor.before(editor, end, { unit: "character", }); const startOfTextNode = Editor.point(editor, currentNodePath, { edge: "start", }); while ( Editor.string(editor, Editor.range(editor, start, end)) !== " " && !Point.isBefore(start, startOfTextNode) ) { end = start; start = Editor.before(editor, end, { unit: "character" }); } const lastWordRange = Editor.range(editor, end, startPointOfLastCharacter); const lastWord = Editor.string(editor, lastWordRange);
Hier ist ein Diagramm, das zeigt, wohin diese verschiedenen Punkte zeigen, wenn wir feststellen, dass das letzte eingegebene Wort ABCDE
ist.
Beachten Sie, dass start
und end
die Punkte vor und nach dem dortigen Leerzeichen sind. Ebenso sind startPointOfLastCharacter
und cursorPoint
die Punkte vor und nach dem gerade eingefügten Leerzeichen. Daher gibt uns [end,startPointOfLastCharacter]
das letzte eingefügte Wort.
Wir protokollieren den Wert von lastWord
in der Konsole und überprüfen die Werte während der Eingabe.
Nachdem wir nun hergeleitet haben, was das letzte Wort war, das der Benutzer eingegeben hat, überprüfen wir, ob es sich tatsächlich um eine URL handelt, und konvertieren diesen Bereich in ein Link-Objekt. Diese Konvertierung sieht ähnlich aus wie die Symbolleisten-Link-Schaltfläche, die den ausgewählten Text eines Benutzers in einen Link konvertiert.
if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }
onChange
identifyLinksInTextIfAny
daher möchten wir die Dokumentstruktur nicht innerhalb von onChange
. Daher stellen wir dieses Update mit einem Promise.resolve().then(..)
Aufruf in unsere Aufgabenwarteschlange.
Mal sehen, wie die Logik in Aktion zusammenkommt! Wir überprüfen, ob wir Links am Ende, in der Mitte oder am Anfang eines Textknotens einfügen.
Damit haben wir die Funktionalitäten für Links im Editor abgeschlossen und fahren mit Bildern fort.
Umgang mit Bildern
In diesem Abschnitt konzentrieren wir uns auf das Hinzufügen von Unterstützung zum Rendern von Bildknoten, zum Hinzufügen neuer Bilder und zum Aktualisieren von Bildunterschriften. Bilder würden in unserer Dokumentstruktur als Void-Knoten dargestellt. Void-Knoten in SlateJS (analog zu Void-Elementen in der HTML-Spezifikation) sind so beschaffen, dass ihr Inhalt kein bearbeitbarer Text ist. Dadurch können wir Bilder als Leerstellen rendern. Aufgrund der Flexibilität von Slate beim Rendern können wir immer noch unsere eigenen bearbeitbaren Elemente innerhalb von Void-Elementen rendern – was wir für die Bearbeitung von Bildunterschriften tun werden. SlateJS hat ein Beispiel, das zeigt, wie Sie einen vollständigen Rich-Text-Editor in ein Void-Element einbetten können.
Zum Rendern von Bildern konfigurieren wir den Editor so, dass er Bilder als Void-Elemente behandelt und eine Renderimplementierung bereitstellt, wie Bilder gerendert werden sollen. Wir fügen unserem ExampleDocument ein Bild hinzu und überprüfen, ob es mit der Beschriftung korrekt gerendert wird.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const { isVoid } = editor; editor.isVoid = (element) => { return ["image"].includes(element.type) || isVoid(element); }; ... } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "image": return <Image {...props} />; ... `` `` # src/components/Image.js function Image({ attributes, children, element }) { return ( <div contentEditable={false} {...attributes}> <div className={classNames({ "image-container": true, })} > <img src={String(element.url)} alt={element.caption} className={"image"} /> <div className={"image-caption-read-mode"}>{element.caption}</div> </div> {children} </div> ); }
Zwei Dinge, die Sie sich merken sollten, wenn Sie versuchen, leere Knoten mit SlateJS zu rendern:
- Für das Root-DOM-Element sollte
contentEditable={false}
festgelegt sein, damit SlateJS seinen Inhalt so behandelt. Ohne dies kann SlateJS bei der Interaktion mit dem void-Element versuchen, Auswahlen usw. zu berechnen, und als Ergebnis brechen. - Selbst wenn Void-Knoten keine untergeordneten Knoten haben (wie beispielsweise unser Bildknoten), müssen wir dennoch untergeordnete Knoten rendern und einen leeren
children
als untergeordnetes Element bereitstellen (sieheExampleDocument
unten), der als Auswahlpunkt des Void behandelt wird Element von SlateJS
Wir aktualisieren jetzt das ExampleDocument
, um ein Bild hinzuzufügen und zu überprüfen, ob es mit der Beschriftung im Editor angezeigt wird.
# src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ];
Konzentrieren wir uns nun auf die Bearbeitung von Untertiteln. Wir möchten, dass dies ein nahtloses Erlebnis für den Benutzer ist, indem wir, wenn er auf die Beschriftung klickt, eine Texteingabe anzeigen, in der er die Beschriftung bearbeiten kann. Wenn sie außerhalb der Eingabe klicken oder die EINGABETASTE drücken, behandeln wir dies als Bestätigung zum Anwenden der Beschriftung. Wir aktualisieren dann die Beschriftung auf dem Bildknoten und schalten die Beschriftung wieder in den Lesemodus. Lassen Sie es uns in Aktion sehen, damit wir eine Vorstellung davon haben, was wir bauen.
Lassen Sie uns unsere Image-Komponente aktualisieren, damit sie einen Status für die Lese-Bearbeitungs-Modi der Beschriftung hat. Wir aktualisieren den lokalen Beschriftungsstatus, wenn der Benutzer ihn aktualisiert, und wenn er ausklickt ( onBlur
) oder RETURN drückt ( onKeyDown
), wenden wir die Beschriftung auf den Knoten an und wechseln wieder in den Lesemodus.
const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...
Damit ist die Funktionalität zum Bearbeiten von Untertiteln abgeschlossen. Wir gehen jetzt dazu über, eine Möglichkeit für Benutzer hinzuzufügen, Bilder in den Editor hochzuladen. Lassen Sie uns eine Symbolleistenschaltfläche hinzufügen, mit der Benutzer ein Bild auswählen und hochladen können.
# src/components/Toolbar.js const onImageSelected = useImageUploadHandler(editor, previousSelection); return ( <div className="toolbar"> .... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> <i className={`bi ${getIconForButton("image")}`} /> <input type="file" className="image-upload-input" accept="image/png, image/jpeg" onChange={onImageSelected} /> </> } /> </div>
Da wir mit Bild-Uploads arbeiten, könnte der Code ziemlich wachsen, also verschieben wir die Handhabung des Bild-Uploads auf einen Hook useImageUploadHandler
, der einen an das file-input-Element angehängten Callback ausgibt. Wir werden kurz darauf eingehen, warum es den Zustand previousSelection
benötigt.
Bevor wir useImageUploadHandler
implementieren, richten wir den Server so ein, dass er ein Bild hochladen kann. Wir richten einen Express-Server ein und installieren zwei weitere Pakete – cors
und multer
, die das Hochladen von Dateien für uns übernehmen.
yarn add express cors multer
Dann fügen wir ein src/server.js
Skript hinzu, das den Express-Server mit cors und multer konfiguriert und einen Endpunkt /upload
bereitstellt, auf den wir das Bild hochladen.
# src/server.js const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "./public/photos/"); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); var upload = multer({ storage: storage }).single("photo"); app.post("/upload", function (req, res) { upload(req, res, function (err) { if (err instanceof multer.MulterError) { return res.status(500).json(err); } else if (err) { return res.status(500).json(err); } return res.status(200).send(req.file); }); }); app.use(cors()); app.listen(port, () => console.log(`Listening on port ${port}`));
Jetzt, da wir die Servereinrichtung haben, können wir uns auf den Bild-Upload konzentrieren. Wenn der Benutzer ein Bild hochlädt, kann es einige Sekunden dauern, bis das Bild hochgeladen wird und wir eine URL dafür haben. Wir tun jedoch etwas, um dem Benutzer eine sofortige Rückmeldung zu geben, dass der Bild-Upload im Gange ist, damit er weiß, dass das Bild in den Editor eingefügt wird. Hier sind die Schritte, die wir implementieren, damit dieses Verhalten funktioniert -
- Sobald der Benutzer ein Bild auswählt, fügen wir einen Bildknoten an der Cursorposition des Benutzers ein, auf dem ein
isUploading
-Flag gesetzt ist, damit wir dem Benutzer einen Ladezustand anzeigen können. - Wir senden die Anfrage an den Server, um das Bild hochzuladen.
- Sobald die Anfrage abgeschlossen ist und wir eine Bild-URL haben, setzen wir diese auf das Bild und entfernen den Ladezustand.
Beginnen wir mit dem ersten Schritt, in dem wir den Bildknoten einfügen. Der knifflige Teil hier ist, dass wir bei der Auswahl auf dasselbe Problem stoßen wie bei der Link-Schaltfläche in der Symbolleiste. Sobald der Benutzer auf die Schaltfläche Bild in der Symbolleiste klickt, verliert der Editor den Fokus und die Auswahl wird null
. Wenn wir versuchen, ein Bild einzufügen, wissen wir nicht, wo der Cursor des Benutzers war. Das Verfolgen von previousSelection
gibt uns diesen Ort und wir verwenden ihn, um den Knoten einzufügen.
# src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }
Wenn wir den neuen Bildknoten einfügen, weisen wir ihm mit dem uuid-Paket auch eine Kennungs- id
zu. Wir werden in der Implementierung von Schritt (3) diskutieren, warum wir das brauchen. Wir aktualisieren jetzt die Bildkomponente so, dass sie das isUploading
Flag verwendet, um einen Ladezustand anzuzeigen.
{!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}
Damit ist die Implementierung von Schritt 1 abgeschlossen. Lassen Sie uns überprüfen, ob wir in der Lage sind, ein Bild zum Hochladen auszuwählen, und sehen, wie der Bildknoten mit einem Ladeindikator dort eingefügt wird, wo er in das Dokument eingefügt wurde.
In Schritt (2) verwenden wir die Axois-Bibliothek, um eine Anfrage an den Server zu senden.
export default function useImageUploadHandler(editor, previousSelection) { return useCallback((event) => { .... Transforms.insertNodes( … {at: previousSelection, select: true} ); axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { // update the image node. }) .catch((error) => { // Fire another Transform.setNodes to set an upload failed state on the image }); }, [...]); }
Wir überprüfen, ob der Bild-Upload funktioniert und das Bild im Ordner „ public/photos
“ der App angezeigt wird. Nachdem der Bild-Upload abgeschlossen ist, fahren wir mit Schritt (3) fort, wo wir die URL für das Bild in der Funktion resolve()
des Axios-Versprechens festlegen möchten. Wir könnten das Bild mit Transforms.setNodes
aktualisieren, aber wir haben ein Problem – wir haben den Pfad zum neu eingefügten Bildknoten nicht. Mal sehen, welche Möglichkeiten wir haben, um zu diesem Bild zu gelangen —
- Können wir nicht
editor.selection
verwenden, da sich die Auswahl auf dem neu eingefügten Bildknoten befinden muss? Wir können dies nicht garantieren, da der Benutzer möglicherweise während des Hochladens des Bildes woanders geklickt hat und sich die Auswahl geändert hat. - Wie wäre es mit der Verwendung von
previousSelection
, mit der wir den Bildknoten überhaupt eingefügt haben? Aus dem gleichen Grund können wir nichteditor.selection
verwenden, wir könnenpreviousSelection
nicht verwenden, da es sich möglicherweise auch geändert hat. - SlateJS verfügt über ein Verlaufsmodul, das alle Änderungen am Dokument verfolgt. Wir könnten dieses Modul verwenden, um den Verlauf zu durchsuchen und den zuletzt eingefügten Bildknoten zu finden. Dies ist auch nicht ganz zuverlässig, wenn das Hochladen des Bildes länger gedauert hat und der Benutzer mehr Bilder in verschiedene Teile des Dokuments eingefügt hat, bevor der erste Upload abgeschlossen ist.
- Derzeit gibt die API von
Transform.insertNodes
keine Informationen über die eingefügten Knoten zurück. Wenn es die Pfade zu den eingefügten Knoten zurückgeben könnte, könnten wir das verwenden, um den genauen Bildknoten zu finden, den wir aktualisieren sollten.
Da keiner der oben genannten Ansätze funktioniert, wenden wir eine id
auf den eingefügten Bildknoten (in Schritt (1)) an und verwenden dieselbe id
erneut, um ihn zu lokalisieren, wenn der Bild-Upload abgeschlossen ist. Damit sieht unser Code für Schritt (3) wie folgt aus —
axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { const newImageEntry = Editor.nodes(editor, { match: (n) => n.id === id, }); if (newImageEntry == null) { return; } Transforms.setNodes( editor, { isUploading: false, url: `/photos/${fileName}` }, { at: newImageEntry[1] } ); }) .catch((error) => { // Fire another Transform.setNodes to set an upload failure state // on the image. });
Nachdem alle drei Schritte abgeschlossen sind, sind wir bereit, den Bildupload End-to-End zu testen.
Damit haben wir Bilder für unseren Editor abgeschlossen. Aktuell zeigen wir unabhängig vom Bild einen Ladezustand gleicher Größe an. Dies kann für den Benutzer ein störendes Erlebnis sein, wenn der Ladestatus nach Abschluss des Uploads durch ein drastisch kleineres oder größeres Bild ersetzt wird. Eine gute Nachbereitung des Upload-Erlebnisses besteht darin, die Bildabmessungen vor dem Upload abzurufen und einen Platzhalter dieser Größe anzuzeigen, damit der Übergang nahtlos ist. Der Hook, den wir oben hinzufügen, könnte erweitert werden, um andere Medientypen wie Videos oder Dokumente zu unterstützen und diese Arten von Knoten ebenfalls zu rendern.
Fazit
In diesem Artikel haben wir einen WYSIWYG-Editor erstellt, der über einen grundlegenden Satz von Funktionen und einige Mikrobenutzererfahrungen wie Link-Erkennung, In-Place-Link-Bearbeitung und Bearbeitung von Bildunterschriften verfügt, die uns geholfen haben, tiefer in SlateJS und Konzepte der Rich-Text-Bearbeitung einzusteigen Allgemeines. Wenn Sie dieser Problembereich rund um Rich Text Editing oder Textverarbeitung interessiert, könnten einige der coolen Probleme sein, denen Sie nachgehen sollten:
- Zusammenarbeit
- Eine reichhaltigere Textbearbeitungserfahrung, die Textausrichtungen, Inline-Bilder, Kopieren und Einfügen, Ändern von Schrift- und Textfarben usw. unterstützt.
- Importieren aus gängigen Formaten wie Word-Dokumenten und Markdown.
Wenn Sie mehr über SlateJS erfahren möchten, finden Sie hier einige hilfreiche Links.
- SlateJS-Beispiele
Viele Beispiele, die über die Grundlagen hinausgehen und Funktionalitäten aufbauen, die normalerweise in Editoren wie Search & Highlight, Markdown Preview und Mentions zu finden sind. - API-Dokumente
Verweis auf viele Hilfsfunktionen, die von SlateJS verfügbar gemacht werden und die man möglicherweise griffbereit halten möchte, wenn man versucht, komplexe Abfragen/Transformationen an SlateJS-Objekten durchzuführen.
Schließlich ist der Slack Channel von SlateJS eine sehr aktive Community von Webentwicklern, die Rich-Text-Editing-Anwendungen mit SlateJS erstellen, und ein großartiger Ort, um mehr über die Bibliothek zu erfahren und bei Bedarf Hilfe zu erhalten.