Tworzenie edytora tekstu sformatowanego (WYSIWYG)

Opublikowany: 2022-03-10
Krótkie podsumowanie ↬ W tym artykule dowiemy się, jak zbudować edytor WYSIWYG/Rich-Text, który obsługuje tekst sformatowany, obrazy, linki i niektóre niuanse z aplikacji do edycji tekstu. Użyjemy SlateJS do zbudowania powłoki edytora, a następnie dodamy pasek narzędzi i niestandardowe konfiguracje. Kod aplikacji jest dostępny w witrynie GitHub w celach informacyjnych.

W ostatnich latach dziedzina tworzenia i reprezentacji treści na platformach cyfrowych doświadczyła ogromnych zakłóceń. Powszechny sukces produktów takich jak Quip, Google Docs i Dropbox Paper pokazał, jak firmy ścigają się, aby zapewnić jak najlepsze wrażenia dla twórców treści w domenie korporacyjnej i próbują znaleźć innowacyjne sposoby przełamania tradycyjnych schematów udostępniania i konsumowania treści. Korzystając z ogromnego zasięgu platform mediów społecznościowych, pojawiła się nowa fala niezależnych twórców treści, którzy używają platform takich jak Medium do tworzenia treści i udostępniania ich odbiorcom.

Ponieważ tak wiele osób z różnych zawodów i środowisk próbuje tworzyć treści na tych produktach, ważne jest, aby te produkty zapewniały wydajne i bezproblemowe doświadczenie w tworzeniu treści oraz miały zespoły projektantów i inżynierów, którzy z czasem rozwijają pewien poziom wiedzy w tej dziedzinie. . W tym artykule staramy się nie tylko położyć podwaliny pod budowę edytora, ale także dać czytelnikom wgląd w to, jak małe bryłki funkcji po połączeniu mogą stworzyć wspaniałe wrażenia użytkownika dla twórcy treści.

Zrozumienie struktury dokumentu

Zanim zagłębimy się w tworzenie edytora, przyjrzyjmy się strukturze dokumentu dla edytora tekstu sformatowanego i jakie są różne typy struktur danych.

Węzły dokumentów

Węzły dokumentu są używane do reprezentowania zawartości dokumentu. Typowe typy węzłów, które może zawierać dokument z tekstem sformatowanym, to akapity, nagłówki, obrazy, filmy, bloki kodu i cytaty. Niektóre z nich mogą zawierać inne węzły jako dzieci (np. węzły akapitu zawierają węzły tekstowe w sobie). Węzły zawierają również wszelkie właściwości specyficzne dla obiektu, który reprezentują, które są potrzebne do renderowania tych węzłów w edytorze. (np. węzły obrazu zawierają właściwość src obrazu, bloki kodu mogą zawierać właściwość language i tak dalej).

Istnieją w dużej mierze dwa typy węzłów, które reprezentują sposób, w jaki powinny być renderowane -

  • Węzły blokowe (analogicznie do koncepcji HTML elementów blokowych), z których każdy jest renderowany w nowej linii i zajmuje dostępną szerokość. Węzły blokowe mogą zawierać w sobie inne węzły blokowe lub węzły śródliniowe. Zauważono tutaj, że węzły najwyższego poziomu dokumentu zawsze będą węzłami blokowymi.
  • Węzły Inline (analogicznie do koncepcji HTML elementów Inline), które zaczynają renderować w tym samym wierszu, co poprzedni węzeł. Istnieją pewne różnice w sposobie przedstawiania elementów wbudowanych w różnych bibliotekach edycyjnych. SlateJS pozwala, aby elementy wbudowane same w sobie były węzłami. DraftJS, kolejna popularna biblioteka do edycji tekstu sformatowanego, umożliwia wykorzystanie koncepcji encji do renderowania elementów wbudowanych. Linki i obrazy wbudowane to przykłady węzłów wbudowanych.
  • Puste węzły — SlateJS pozwala również na tę trzecią kategorię węzłów, których użyjemy w dalszej części tego artykułu do renderowania multimediów.

Jeśli chcesz dowiedzieć się więcej o tych kategoriach, dokumentacja SlateJS dotycząca węzłów jest dobrym miejscem do rozpoczęcia.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Atrybuty

Podobnie do koncepcji atrybutów w HTML, atrybuty w dokumencie z tekstem sformatowanym są używane do reprezentowania właściwości innych niż zawartość węzła lub jego elementów potomnych. Na przykład węzeł tekstowy może mieć atrybuty stylu znakowego, które mówią nam, czy tekst jest pogrubiony/kursywa/podkreślony i tak dalej. Chociaż ten artykuł przedstawia nagłówki jako same węzły, innym sposobem ich przedstawienia może być to, że węzły mają style akapitowe ( paragraph & h1-h6 ) jako atrybuty.

Poniższy obraz przedstawia przykład, w jaki sposób struktura dokumentu (w JSON) jest opisana na bardziej szczegółowym poziomie za pomocą węzłów i atrybutów wyróżniających niektóre elementy w strukturze po lewej stronie.

Obraz przedstawiający przykładowy dokument w edytorze z reprezentacją struktury po lewej stronie
Przykładowy dokument i jego strukturalna reprezentacja. (duży podgląd)

Niektóre z rzeczy, które warto tutaj przywołać ze strukturą, to:

  • Węzły tekstowe są reprezentowane jako {text: 'text content'}
  • Właściwości węzłów są przechowywane bezpośrednio w węźle (np. url dla linków i caption dla obrazów)
  • Specyficzna dla SlateJS reprezentacja atrybutów tekstowych powoduje rozbicie węzłów tekstowych na ich własne węzły, jeśli zmieni się styl znaku. Stąd tekst „ Duis aute irure dolor ” jest własnym węzłem tekstowym z bold: true ustawiona na nim. To samo dotyczy kursywy, podkreślenia i tekstu w stylu kodu w tym dokumencie.

Lokalizacje i wybór

Tworząc edytor tekstu sformatowanego, ważne jest, aby zrozumieć, w jaki sposób najbardziej szczegółowa część dokumentu (np. znak) może być reprezentowana za pomocą pewnego rodzaju współrzędnych. Pomaga nam to poruszać się po strukturze dokumentu w czasie wykonywania, aby zrozumieć, gdzie w hierarchii dokumentów się znajdujemy. Co najważniejsze, obiekty lokalizacji umożliwiają nam reprezentowanie wyboru użytkownika, który jest dość szeroko stosowany do dostosowywania wrażenia użytkownika edytora w czasie rzeczywistym. Użyjemy selekcji do zbudowania naszego paska narzędzi w dalszej części tego artykułu. Przykładami mogą być:

  • Czy kursor użytkownika jest aktualnie w linku, może powinniśmy pokazać mu menu do edycji/usuwania linku?
  • Czy użytkownik wybrał obraz? Może damy im menu do zmiany rozmiaru obrazu.
  • Jeśli użytkownik zaznaczy określony tekst i naciśnie przycisk USUŃ, określamy, jaki był tekst zaznaczony przez użytkownika i usuwamy go z dokumentu.

Dokument SlateJS dotyczący lokalizacji szczegółowo wyjaśnia te struktury danych, ale przechodzimy przez nie tutaj szybko, ponieważ używamy tych terminów w różnych przypadkach w artykule i pokazujemy przykład na poniższym diagramie.

  • Ścieżka
    Reprezentowana przez tablicę liczb, ścieżka jest sposobem na dotarcie do węzła w dokumencie. Na przykład ścieżka [2,3] reprezentuje trzeci węzeł podrzędny drugiego węzła w dokumencie.
  • Punkt
    Bardziej szczegółowa lokalizacja treści reprezentowana przez ścieżkę + przesunięcie. Na przykład punkt {path: [2,3], offset: 14} reprezentuje 14 znak trzeciego węzła podrzędnego wewnątrz drugiego węzła dokumentu.
  • Zakres
    Para punktów (zwanych anchor i focus ), które reprezentują zakres tekstu wewnątrz dokumentu. Ta koncepcja pochodzi z interfejsu API wyboru sieci Web, w którym anchor to miejsce, w którym rozpoczyna się wybór użytkownika, a focus to miejsce, w którym się on kończy. Zwinięty zakres/zaznaczenie wskazuje, gdzie zakotwiczenie i punkty ostrości są takie same (na przykład migający kursor podczas wprowadzania tekstu).

Jako przykład załóżmy, że wybór użytkownika w powyższym przykładzie dokumentu to ipsum :

Obraz z tekstem `ipsum` zaznaczonym w edytorze
Użytkownik wybiera słowo ipsum . (duży podgląd)

Wybór użytkownika można przedstawić jako:

 { 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' }`

Konfigurowanie edytora

W tej sekcji skonfigurujemy aplikację i uzyskamy podstawowy edytor tekstu sformatowanego współpracujący ze SlateJS. Aplikacja szablonowa byłaby create-react-app z dodanymi do niej zależnościami SlateJS. UI aplikacji budujemy przy użyciu komponentów z react-bootstrap . Zacznijmy!

Utwórz folder o nazwie wysiwyg-editor i uruchom poniższe polecenie z wnętrza katalogu, aby skonfigurować aplikację React. Następnie uruchamiamy polecenie yarn start , które powinno uruchomić lokalny serwer sieciowy (port domyślnie ustawiony na 3000) i wyświetlić ekran powitalny React.

 npx create-react-app . yarn start

Następnie przechodzimy do dodawania zależności SlateJS do aplikacji.

 yarn add slate slate-react

slate to podstawowy pakiet SlateJS, a slate-react zawiera zestaw komponentów React, których użyjemy do renderowania edytorów Slate. SlateJS udostępnia więcej pakietów zorganizowanych według funkcjonalności, które można rozważyć dodanie do ich edytora.

Najpierw tworzymy folder utils , który zawiera wszystkie moduły narzędziowe, które tworzymy w tej aplikacji. Zaczynamy od utworzenia pliku ExampleDocument.js , który zwraca podstawową strukturę dokumentu zawierającą akapit z tekstem. Ten moduł wygląda jak poniżej:

 const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;

Dodaliśmy teraz folder o nazwie components , który będzie zawierał wszystkie nasze komponenty React i wykonamy następujące czynności:

  • Dodaj do niego nasz pierwszy komponent React Editor.js . Na razie zwraca tylko div .
  • Zaktualizuj komponent App.js , aby utrzymywał dokument w stanie, który jest zainicjowany w naszym ExampleDocument powyżej.
  • Wyrenderuj edytor wewnątrz aplikacji i przekaż stan dokumentu i procedurę obsługi onChange do edytora, aby nasz stan dokumentu był aktualizowany, gdy użytkownik go aktualizuje.
  • Używamy komponentów Nav React bootstrap, aby dodać pasek nawigacji do aplikacji.

Komponent App.js wygląda teraz tak, jak poniżej:

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

Wewnątrz komponentu Editor tworzymy instancję edytora SlateJS i trzymamy go wewnątrz useMemo , aby obiekt nie zmieniał się pomiędzy kolejnymi renderowaniami.

 // dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);

createEditor udostępnia nam instancję editor SlateJS, z której intensywnie korzystamy za pośrednictwem aplikacji, aby uzyskać dostęp do selekcji, uruchamiać transformacje danych i tak dalej. withReact to wtyczka SlateJS, która dodaje zachowania React i DOM do obiektu edytora. Wtyczki SlateJS to funkcje JavaScript, które odbierają obiekt editor i dołączają do niego pewną konfigurację. Pozwala to programistom internetowym na dodawanie konfiguracji do instancji edytora SlateJS w sposób umożliwiający tworzenie kompozycji.

Teraz importujemy i renderujemy komponenty <Slate /> i <Editable /> ze SlateJS za pomocą właściwości dokumentu, którą otrzymujemy z App.js. Slate ujawnia kilka kontekstów React, których używamy, aby uzyskać dostęp w kodzie aplikacji. Editable to składnik, który renderuje hierarchię dokumentów do edycji. Ogólnie moduł Editor.js na tym etapie wygląda tak, jak poniżej:

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

W tym momencie dodaliśmy niezbędne komponenty Reacta, a edytor zapełniliśmy przykładowym dokumentem. Nasz edytor powinien być teraz skonfigurowany, umożliwiając nam wpisywanie i zmianę treści w czasie rzeczywistym — jak na poniższym screencastu.

Podstawowa konfiguracja edytora w akcji

Przejdźmy teraz do następnej sekcji, w której konfigurujemy edytor do renderowania stylów znaków i węzłów akapitów.

NIESTANDARDOWE RENDEROWANIE TEKSTU I PASEK NARZĘDZI

Węzły stylu akapitowego

Obecnie nasz edytor używa domyślnego renderowania SlateJS dla wszystkich nowych typów węzłów, które możemy dodać do dokumentu. W tej sekcji chcemy mieć możliwość renderowania węzłów nagłówka. Aby móc to zrobić, dostarczamy właściwość funkcji renderElement do komponentów Slate. Ta funkcja jest wywoływana przez Slate w czasie wykonywania, gdy próbuje przeszukiwać drzewo dokumentu i renderować każdy węzeł. Funkcja renderElement pobiera trzy parametry —

  • attributes
    Specyfika SlateJS, którą należy zastosować do elementu DOM najwyższego poziomu zwracanego przez tę funkcję.
  • element
    Sam obiekt węzła, ponieważ istnieje w strukturze dokumentu
  • children
    Dzieci tego węzła zgodnie z definicją w strukturze dokumentu.

Dodajemy naszą implementację renderElement do punktu zaczepienia o nazwie useEditorConfig , w którym dodamy więcej konfiguracji edytora. Następnie używamy haka na instancji edytora wewnątrz 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} />; } }

Ponieważ ta funkcja daje nam dostęp do element (który jest samym węzłem), możemy dostosować renderElement , aby zaimplementować bardziej dostosowane renderowanie, które nie tylko sprawdza element.type . Na przykład możesz mieć węzeł obrazu, który ma właściwość isInline , której moglibyśmy użyć do zwrócenia innej struktury DOM, która pomoże nam renderować obrazy w tekście w porównaniu z obrazami blokowymi.

Teraz aktualizujemy komponent Editor, aby używał tego haka, jak poniżej:

 const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );

Po wdrożeniu niestandardowego renderowania aktualizujemy ExampleDocument, aby zawierał nasze nowe typy węzłów i sprawdzamy, czy renderują się poprawnie w edytorze.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
Obraz przedstawiający różne nagłówki i węzły akapitów renderowane w edytorze
Nagłówki i węzły akapitu w Edytorze. (duży podgląd)

Style postaci

Podobnie jak renderElement , SlateJS udostępnia właściwość funkcji o nazwie renderLeaf, której można użyć do dostosowania renderowania węzłów tekstowych ( Leaf odnosi się do węzłów tekstowych, które są liśćmi/węzłami najniższego poziomu drzewa dokumentu). Idąc za przykładem renderElement , piszemy implementację dla 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>; }

Ważnym spostrzeżeniem powyższej implementacji jest to, że pozwala nam ona respektować semantykę HTML dla stylów znaków. Ponieważ renderLeaf daje nam dostęp do samego leaf węzła tekstowego, możemy dostosować funkcję, aby zaimplementować bardziej dostosowane renderowanie. Na przykład możesz mieć sposób, aby umożliwić użytkownikom wybranie highlightColor dla tekstu i sprawdzenie właściwości liścia w tym miejscu, aby dołączyć odpowiednie style.

Teraz aktualizujemy komponent Editor, aby używał powyższego, ExampleDocument , aby miał kilka węzłów tekstowych w akapicie z kombinacjami tych stylów i sprawdzamy, czy są one renderowane zgodnie z oczekiwaniami w edytorze za pomocą tagów semantycznych, których użyliśmy.

 # 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 }, ], }, 
Style znaków w interfejsie użytkownika i sposób ich renderowania w drzewie DOM
Style znaków w interfejsie użytkownika i sposób ich renderowania w drzewie DOM. (duży podgląd)

Dodawanie paska narzędzi

Zacznijmy od dodania nowego komponentu Toolbar.js , do którego dodamy kilka przycisków stylów znakowych oraz menu rozwijane stylów akapitowych, które połączymy później w tej sekcji.

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

Wydzielamy przyciski do komponentu ToolbarButton , który jest opakowaniem wokół komponentu React Bootstrap Button. Następnie renderujemy pasek narzędzi nad komponentem Editable inside Editor i sprawdzamy, czy pasek narzędzi pojawia się w aplikacji.

Obraz przedstawiający pasek narzędzi z przyciskami renderowanymi nad edytorem
Pasek narzędzi z przyciskami (duży podgląd)

Oto trzy kluczowe funkcje, które potrzebujemy do obsługi paska narzędzi:

  1. Kiedy kursor użytkownika znajduje się w określonym miejscu w dokumencie i kliknie jeden z przycisków stylu znaku, musimy zmienić styl tekstu, który może wpisać jako następny.
  2. Kiedy użytkownik wybierze zakres tekstu i kliknie jeden z przycisków stylu znaków, musimy przełączyć styl dla tej konkretnej sekcji.
  3. Gdy użytkownik zaznaczy zakres tekstu, chcemy zaktualizować menu rozwijane stylu akapitu, aby odzwierciedlało typ akapitu zaznaczenia. Jeśli wybierają inną wartość z zaznaczenia, chcemy zaktualizować styl akapitu całego zaznaczenia tak, aby był taki, jaki wybrali.

Przyjrzyjmy się, jak te funkcjonalności działają w Edytorze, zanim zaczniemy je wdrażać.

Przełączanie stylów postaci

Słuchanie selekcji

Najważniejszą rzeczą, której pasek narzędzi potrzebuje do wykonywania powyższych funkcji, jest stan zaznaczenia dokumentu. W chwili pisania tego artykułu SlateJS nie udostępnia metody onSelectionChange , która mogłaby dać nam najnowszy stan selekcji dokumentu. Jednak wraz ze zmianą wyboru w edytorze, SlateJS wywołuje metodę onChange , nawet jeśli zawartość dokumentu nie uległa zmianie. Używamy tego jako sposobu na otrzymanie powiadomienia o zmianie wyboru i zapisanie go w stanie komponentu Editor . Abstrahujemy to do useSelection , w którym moglibyśmy dokonać bardziej optymalnej aktualizacji stanu zaznaczenia. Jest to ważne, ponieważ wybór jest właściwością, która zmienia się dość często w przypadku wystąpienia edytora WYSIWYG.

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

Używamy tego haka w komponencie Editor jak poniżej i przekazujemy zaznaczenie do komponentu Toolbar.

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

Uwzględnienie wydajności

W aplikacji, w której mamy znacznie większą bazę kodu edytora z dużo większą liczbą funkcji, ważne jest, aby przechowywać i nasłuchiwać zmian selekcji w wydajny sposób (jak przy użyciu jakiejś biblioteki zarządzania stanami), ponieważ komponenty nasłuchujące zmian selekcji również mogą się renderować często. Jednym ze sposobów, aby to zrobić, jest umieszczenie zoptymalizowanych selektorów na górze stanu Wybór, które przechowują określone informacje o wyborze. Na przykład edytor może chcieć renderować menu zmiany rozmiaru obrazu, gdy wybrany jest obraz. W takim przypadku pomocne może być obliczenie selektora isImageSelected na podstawie stanu zaznaczenia edytora, a menu Obraz będzie renderowane ponownie tylko wtedy, gdy zmieni się wartość tego selektora. Reselect firmy Redux jest jedną z takich bibliotek, która umożliwia budowanie selektorów.

Dopiero później używamy selection na pasku narzędzi, ale przekazanie go jako rekwizytu powoduje, że pasek narzędzi jest ponownie renderowany za każdym razem, gdy zaznaczenie zmieni się w Edytorze. Robimy to, ponieważ nie możemy polegać wyłącznie na zmianie treści dokumentu, aby wywołać ponowne renderowanie w hierarchii ( App -> Editor -> Toolbar ), ponieważ użytkownicy mogą po prostu klikać wokół dokumentu, zmieniając w ten sposób zaznaczenie, ale nigdy nie zmieniając zawartości dokumentu samo.

Przełączanie stylów znaków

Przechodzimy teraz do pobierania aktywnych stylów znaków ze SlateJS i używania tych w Edytorze. Dodajmy nowy moduł JS EditorUtils , który będzie obsługiwał wszystkie funkcje użytkowe, które zbudujemy w przyszłości, aby pobierać/robić rzeczy za pomocą SlateJS. Naszą pierwszą funkcją w module jest getActiveStyles , która udostępnia w edytorze Set aktywnych stylów. Dodajemy również funkcję przełączania stylu w funkcji edytora — 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); } }

Obie funkcje przyjmują jako parametr obiekt editor , który jest instancją Slate, podobnie jak wiele funkcji użytkowych, które dodamy w dalszej części artykułu. i usuń te znaki.Importujemy te funkcje narzędziowe do paska narzędzi i łączymy je z przyciskami, które dodaliśmy wcześniej.

 # 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 to hak Slate, który daje nam dostęp do instancji Slate z kontekstu, w którym została ona dołączona przez komponent &lt;Slate> znajdujący się wyżej w hierarchii renderowania.

Można się zastanawiać, dlaczego używamy tutaj onMouseDown zamiast onClick ? Istnieje otwarty problem Github dotyczący tego, w jaki sposób Slate zmienia selection na null , gdy edytor w jakikolwiek sposób traci fokus. Tak więc, jeśli dołączymy moduły obsługi onClick do naszych przycisków paska narzędzi, selection staje się null , a użytkownicy tracą pozycję kursora, próbując przełączyć styl, co nie jest wspaniałym doświadczeniem. Zamiast tego przełączamy styl, dołączając zdarzenie onMouseDown , które zapobiega resetowaniu zaznaczenia. Innym sposobem na to jest samodzielne śledzenie zaznaczenia, abyśmy wiedzieli, jaki był ostatni wybór i używamy go do przełączania stylów. W dalszej części artykułu wprowadzamy koncepcję previousSelection , ale w celu rozwiązania innego problemu.

SlateJS pozwala nam skonfigurować obsługę zdarzeń w Edytorze. Używamy tego do łączenia skrótów klawiaturowych, aby przełączać style znaków. W tym celu dodajemy obiekt KeyBindings wewnątrz useEditorConfig , w którym udostępniamy procedurę obsługi zdarzenia onKeyDown dołączoną do komponentu Editable . Używamy narzędzia is-hotkey do określenia kombinacji klawiszy i przełączenia odpowiedniego stylu.

 # 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} /> 
Style znaków przełączane za pomocą skrótów klawiaturowych.

Tworzenie rozwijanego menu stylu akapitowego

Przejdźmy do działania menu rozwijanego Style akapitu. Podobnie do tego, jak działają listy rozwijane w stylu akapitu w popularnych aplikacjach do przetwarzania tekstu, takich jak MS Word lub Dokumenty Google, chcemy, aby style bloków najwyższego poziomu w wyborze użytkownika były odzwierciedlane w menu rozwijanym. Jeśli w zaznaczeniu występuje jeden spójny styl, aktualizujemy wartość listy rozwijanej tak, aby była taka. Jeśli jest ich wiele, ustawiamy wartość listy rozwijanej na „Wiele”. To zachowanie musi działać zarówno w przypadku zwiniętych, jak i rozwiniętych zaznaczeń.

Aby zaimplementować to zachowanie, musimy być w stanie znaleźć bloki najwyższego poziomu obejmujące wybór użytkownika. Aby to zrobić, używamy Slate's Editor.nodes — funkcji pomocniczej powszechnie używanej do wyszukiwania węzłów w drzewie filtrowanym według różnych opcji.

 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>

Funkcja pomocnicza przyjmuje instancję Editor i obiekt options , który jest sposobem filtrowania węzłów w drzewie podczas jego przechodzenia. Funkcja zwraca generator NodeEntry . NodeEntry w terminologii Slate to krotka węzła i ścieżka do niego — [node, pathToNode] . Opcje znalezione tutaj są dostępne w większości funkcji pomocniczych Slate. Przyjrzyjmy się, co każdy z nich oznacza:

  • at
    Może to być ścieżka/punkt/zakres, którego funkcja pomocnicza użyłaby do określenia zakresu przechodzenia w dół drzewa. Domyślnie jest to editor.selection , jeśli nie podano. Używamy również wartości domyślnej dla naszego przypadku użycia poniżej, ponieważ interesują nas węzły w ramach wyboru użytkownika.
  • match
    Jest to funkcja dopasowująca, którą można zapewnić, która jest wywoływana na każdym węźle i dołączana, jeśli jest to dopasowanie. Używamy tego parametru w naszej implementacji poniżej, aby filtrować tylko elementy blokujące.
  • mode
    Poinformujmy funkcje pomocnicze, czy interesują nas wszystkie węzły najwyższego lub najniższego poziomu at danej lokalizacji dopasowującej funkcję match . Ten parametr (ustawiony na highest ) pomaga nam uciec przed próbą samodzielnego przechodzenia drzewa w górę, aby znaleźć węzły najwyższego poziomu.
  • universal
    Flaga do wyboru między pełnymi lub częściowymi dopasowaniami węzłów. (Problem GitHub z propozycją tej flagi zawiera kilka przykładów wyjaśniających to)
  • reverse
    Jeśli wyszukiwanie węzłów powinno odbywać się w odwrotnym kierunku do punktu początkowego i końcowego przekazanej lokalizacji.
  • voids
    Czy wyszukiwanie powinno filtrować tylko do elementów void.

SlateJS udostępnia wiele funkcji pomocniczych, które pozwalają wyszukiwać węzły na różne sposoby, przemierzać drzewo, aktualizować węzły lub selekcje w złożony sposób. Warto zagłębić się w niektóre z tych interfejsów (wymienionych pod koniec tego artykułu) podczas budowania złożonych funkcji edycyjnych na Slate.

Na tym tle funkcji pomocniczej poniżej znajduje się implementacja 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; }

Uwzględnienie wydajności

Obecna implementacja Editor.nodes znajduje wszystkie węzły w drzewie na wszystkich poziomach, które znajdują się w zakresie parametru at , a następnie uruchamia na nim filtry dopasowania (sprawdź nodeEntries i filtrowanie później — source). Jest to w porządku w przypadku mniejszych dokumentów. Jednak w naszym przypadku użycia, jeśli użytkownik wybrał, powiedzmy 3 nagłówki i 2 akapity (każdy akapit zawiera powiedzmy 10 węzłów tekstowych), przejdzie przez co najmniej 25 węzłów (3 + 2 + 2*10) i spróbuje uruchomić filtry na nich. Ponieważ już wiemy, że interesują nas tylko węzły najwyższego poziomu, możemy znaleźć indeksy początkowe i końcowe bloków najwyższego poziomu z selekcji i samodzielnie wykonać iterację. Taka logika przeszłaby przez tylko 3 wpisy węzłów (2 nagłówki i 1 akapit). Kod do tego wyglądałby mniej więcej tak:

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

Ponieważ dodajemy więcej funkcji do edytora WYSIWYG i musimy często przeszukiwać drzewo dokumentów, ważne jest, aby pomyśleć o najbardziej wydajnych sposobach wykonania tego dla danego przypadku użycia, ponieważ dostępne API lub metody pomocnicze mogą nie zawsze być najbardziej skuteczny sposób, aby to zrobić.

Po zaimplementowaniu getTextBlockStyle przełączanie stylu blokowego jest stosunkowo proste. Jeśli bieżący styl nie jest tym, który użytkownik wybrał z listy rozwijanej, przełączamy styl do tego. Jeśli jest to już to, co wybrał użytkownik, przełączamy go na akapit. Ponieważ w naszej strukturze dokumentu reprezentujemy style akapitowe jako węzły, przełączanie stylu akapitowego zasadniczo oznacza zmianę właściwości type w węźle. Używamy Transforms.setNodes dostarczonych przez Slate do aktualizacji właściwości w węzłach.

Nasza toggleBlockType jest następująca:

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

Na koniec aktualizujemy nasze menu rozwijane w stylu akapitu, aby korzystać z tych funkcji narzędziowych.

 #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> .... ); 
Wybór wielu typów bloków i zmiana typu za pomocą listy rozwijanej.

SPINKI DO MANKIETÓW

W tej sekcji dodamy obsługę pokazywania, dodawania, usuwania i zmiany linków. Dodamy również funkcję Link-Detector — podobną do Google Docs lub MS Word, która skanuje tekst wpisywany przez użytkownika i sprawdza, czy są tam linki. Jeśli tak, są one konwertowane na obiekty łącza, dzięki czemu użytkownik nie musi używać przycisków paska narzędzi, aby to zrobić samodzielnie.

Renderowanie łączy

W naszym edytorze zamierzamy zaimplementować linki jako węzły inline za pomocą SlateJS. Aktualizujemy konfigurację naszego edytora, aby oznaczać łącza jako węzły wbudowane dla SlateJS, a także dostarczamy komponent do renderowania, aby Slate wiedział, jak renderować węzły łącza.

 # 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 }, ], }, ... } 
Image showing Links rendered in the Editor and DOM tree of the editor
Links rendered in the Editor (Large preview)

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> ); 
Link button in Toolbar becomes active if selection is inside a link.

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:

Before and After node structures after a link is inserted
Before and After node structures after a link is inserted. (duży podgląd)

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 Transform.insertNodes 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.

 # 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>

Ponieważ renderujemy LinkEditor poza edytorem, potrzebujemy sposobu na poinformowanie LinkEditor , gdzie znajduje się link w drzewie DOM, aby mógł się renderować w pobliżu edytora. Sposób, w jaki to robimy, to użycie React API Slate, aby znaleźć węzeł DOM odpowiadający wybranemu węzłowi łącza. Następnie używamy getBoundingClientRect() , aby znaleźć granice elementu DOM łącza oraz granice komponentu edytora i obliczyć top i left stronę edytora linków. Aktualizacje kodu do Editor i LinkEditor są następujące —

 # 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 wewnętrznie utrzymuje mapy węzłów do ich odpowiednich elementów DOM. Uzyskujemy dostęp do tej mapy i znajdujemy element DOM linku za pomocą ReactEditor.toDOMNode .

Zaznaczenie wewnątrz linku pokazuje okienko edytora linków.

Jak widać na powyższym filmie, gdy link jest wstawiony i nie ma adresu URL, ponieważ zaznaczenie znajduje się wewnątrz linku, otwiera się edytor linków, umożliwiając w ten sposób użytkownikowi wpisanie adresu URL dla nowo wstawionego linku i w ten sposób zamyka pętlę doświadczenia użytkownika w tym miejscu.

Teraz dodajemy element wejściowy i przycisk do LinkEditor , który pozwala użytkownikowi wpisać adres URL i zastosować go do węzła łącza. Używamy pakietu isUrl do walidacji adresu URL.

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

Po połączeniu elementów formularza sprawdźmy, czy edytor linków działa zgodnie z oczekiwaniami.

Edytor traci zaznaczenie po kliknięciu w edytorze linków

Jak widać na filmie, gdy użytkownik próbuje kliknąć dane wejściowe, edytor linków znika. Dzieje się tak, ponieważ gdy renderujemy edytor linków poza komponentem Editable , kiedy użytkownik kliknie element wejściowy, SlateJS myśli, że edytor stracił fokus i resetuje selection do null , co usuwa LinkEditor ponieważ isLinkActiveAtSelection nie jest już true . Istnieje otwarty problem z usługą GitHub, który mówi o tym zachowaniu Slate. Jednym ze sposobów rozwiązania tego problemu jest śledzenie poprzedniego wyboru użytkownika w miarę jego zmian, a gdy edytor traci fokus, możemy spojrzeć na poprzedni wybór i nadal wyświetlać menu edytora linków, jeśli poprzedni wybór zawierał link. Zaktualizujmy hak useSelection , aby zapamiętał poprzednią selekcję i zwróć go do komponentu Editor.

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

Następnie aktualizujemy logikę w komponencie Editor , aby wyświetlić menu linków, nawet jeśli poprzedni wybór zawierał link.

 # 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={..} ... );

Następnie aktualizujemy LinkEditor , aby używał selectionForLink do wyszukiwania węzła łącza, renderowania pod nim i aktualizowania jego adresu URL.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
Edycja linku za pomocą komponentu LinkEditor.

Wykrywanie linków w tekście

Większość aplikacji do przetwarzania tekstu identyfikuje i konwertuje łącza w tekście na obiekty łącza. Zobaczmy, jak to zadziała w edytorze, zanim zaczniemy go budować.

Linki są wykrywane podczas wpisywania ich przez użytkownika.

Kroki logiki umożliwiające to zachowanie to:

  1. Gdy dokument zmienia się wraz z wpisywaniem przez użytkownika, znajdź ostatni znak wstawiony przez użytkownika. Jeśli ten znak jest spacją, wiemy, że musi być słowo, które mogło pojawić się przed nim.
  2. Jeśli ostatnim znakiem była spacja, oznaczamy to jako granicę końcową słowa, które pojawiło się przed nim. Następnie przechodzimy wstecz znak po znaku w węźle tekstowym, aby znaleźć początek tego słowa. Podczas tego przechodzenia musimy uważać, aby nie przekroczyć krawędzi początku węzła do poprzedniego węzła.
  3. Po znalezieniu wcześniej granic początkowych i końcowych słowa, sprawdzamy ciąg słowa i sprawdzamy, czy był to adres URL. Jeśli tak, przekształcamy go w węzeł łącza.

Nasza logika znajduje się w funkcji util EditorUtils identifyLinksInTextIfAny jest wywoływana w komponencie onChange in Editor .

 # src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );

Oto identifyLinksInTextIfAny z zaimplementowaną logiką dla kroku 1:

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

Istnieją dwie funkcje pomocnicze SlateJS, które ułatwiają pracę.

  • Editor.before — Podaje nam punkt przed określoną lokalizacją. Przyjmuje unit jako parametr, więc możemy poprosić o znak/słowo/blok itp. przed przekazaniem location .
  • Editor.string — Pobiera ciąg z zakresu.

Jako przykład, poniższy diagram wyjaśnia, jakie są wartości tych zmiennych, gdy użytkownik wstawia znak „E”, a jego kursor znajduje się za nim.

Diagram wyjaśniający, dokąd wskazują kursorPoint i startPointOfLastCharacter po kroku 1 z przykładem
cursorPoint i startPointOfLastCharacter po kroku 1 z przykładowym tekstem. (duży podgląd)

Gdyby tekst „ABCDE” był pierwszym węzłem tekstowym pierwszego akapitu w dokumencie, nasze wartości punktowe byłyby —

 cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}

Jeśli ostatni znak był spacją, wiemy, gdzie się zaczął — startPointOfLastCharacter. Przejdźmy do kroku 2, w którym cofamy się znak po znaku, aż znajdziemy inną spację lub początek samego węzła tekstowego.

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

Oto diagram, który pokazuje, dokąd wskazują te różne punkty, gdy znajdziemy ostatnie wprowadzone słowo jako ABCDE .

Schemat wyjaśniający, gdzie znajdują się różne punkty po kroku 2 wykrywania łącza z przykładem
Gdzie różne punkty znajdują się po kroku 2 wykrywania łącza z przykładem. (duży podgląd)

Zauważ, że start i end to punkty przed i po spacji. Podobnie startPointOfLastCharacter i cursorPoint to punkty przed i za wstawionym właśnie użytkownikiem spacji. Stąd [end,startPointOfLastCharacter] daje nam ostatnie wstawione słowo.

Logujemy wartość lastWord do konsoli i weryfikujemy wartości podczas wpisywania.

Konsola rejestruje weryfikację ostatniego słowa wprowadzonego przez użytkownika po logice w kroku 2.

Teraz, gdy wywnioskowaliśmy, jakie ostatnie słowo wpisał użytkownik, weryfikujemy, czy rzeczywiście był to adres URL, i przekształcamy ten zakres w obiekt łącza. Ta konwersja wygląda podobnie do tego, jak przycisk łącza na pasku narzędzi przekształca zaznaczony tekst użytkownika w łącze.

 if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }

identifyLinksInTextIfAny jest wywoływana wewnątrz onChange w Slate, więc nie chcielibyśmy aktualizować struktury dokumentu wewnątrz onChange . Dlatego umieszczamy tę aktualizację w naszej kolejce zadań za pomocą Promise.resolve().then(..) .

Zobaczmy, jak logika łączy się w akcji! Weryfikujemy, czy wstawiamy linki na końcu, w środku lub na początku węzła tekstowego.

Linki wykrywane podczas wpisywania ich przez użytkownika.

W ten sposób zamknęliśmy funkcje linków w edytorze i przechodzimy do obrazów.

Obsługa obrazów

W tej sekcji skupiamy się na dodawaniu obsługi renderowania węzłów obrazu, dodawaniu nowych obrazów i aktualizowaniu podpisów obrazów. Obrazy w naszej strukturze dokumentu byłyby reprezentowane jako węzły Void. Węzły Void w SlateJS (analogicznie do elementów Void w specyfikacji HTML) są takie, że ich zawartość nie jest edytowalnym tekstem. To pozwala nam renderować obrazy jako puste przestrzenie. Ze względu na elastyczność Slate w zakresie renderowania, nadal możemy renderować własne edytowalne elementy wewnątrz elementów Void — co zrobimy w przypadku edycji podpisów obrazów. SlateJS ma przykład, który pokazuje, jak można osadzić cały edytor tekstu sformatowanego wewnątrz elementu Void.

Aby renderować obrazy, konfigurujemy edytor tak, aby traktował obrazy jako elementy Void i zapewniamy implementację renderowania, w jaki sposób obrazy powinny być renderowane. Dodajemy obraz do naszego ExampleDocument i sprawdzamy, czy renderuje się poprawnie z podpisem.

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

Dwie rzeczy do zapamiętania podczas próby renderowania pustych węzłów za pomocą SlateJS:

  • Główny element DOM powinien mieć ustawioną zawartośćEditable contentEditable={false} , aby SlateJS tak traktował jego zawartość. Bez tego, gdy wchodzisz w interakcję z elementem void, SlateJS może próbować obliczyć selekcje itp. iw rezultacie przerwać.
  • Nawet jeśli węzły Void nie mają żadnych węzłów podrzędnych (jak nasz węzeł obrazu jako przykład), nadal musimy renderować children i podać pusty węzeł tekstowy jako dziecko (patrz ExampleDocument poniżej), który jest traktowany jako punkt wyboru Pustki element autorstwa SlateJS

Teraz aktualizujemy ExampleDocument , aby dodać obraz i sprawdzić, czy wyświetla się on z podpisem w edytorze.

 # 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: "" }], }, ]; 
Obraz renderowany w Edytorze
Obraz wyrenderowany w Edytorze. (duży podgląd)

Teraz skupmy się na edycji podpisów. Chcemy, aby użytkownik był bezproblemowy, gdy kliknie podpis, pokazujemy tekst wejściowy, w którym można go edytować. Jeśli klikną poza dane wejściowe lub nacisną klawisz RETURN, traktujemy to jako potwierdzenie zastosowania podpisu. Następnie aktualizujemy podpis w węźle obrazu i przełączamy podpis z powrotem do trybu odczytu. Zobaczmy to w akcji, abyśmy mieli wyobrażenie o tym, co budujemy.

Image Caption Edycja w akcji.

Zaktualizujmy nasz komponent Image, aby zawierał stan dla trybów odczytu i edycji podpisu. Aktualizujemy lokalny stan podpisu, gdy użytkownik go aktualizuje, a kiedy kliknie ( onBlur ) lub naciśnie RETURN ( onKeyDown ), zastosujemy podpis do węzła i ponownie przełączymy się w tryb odczytu.

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

Dzięki temu funkcja edycji podpisów jest kompletna. Przechodzimy teraz do dodawania sposobu, w jaki użytkownicy mogą przesyłać obrazy do edytora. Dodajmy przycisk paska narzędzi, który pozwoli użytkownikom wybrać i przesłać obraz.

 # 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>

Ponieważ pracujemy z przesyłaniem obrazów, kod może nieco się rozrosnąć, więc przenosimy obsługę przesyłania obrazu do haka useImageUploadHandler , który wysyła wywołanie zwrotne dołączone do elementu file-input. Omówimy krótko, dlaczego potrzebuje stanu previousSelection .

Zanim zaimplementujemy useImageUploadHandler , skonfigurujemy serwer, aby mógł przesłać obraz. Konfigurujemy serwer Express i instalujemy dwa inne pakiety — cors i multer , które obsługują przesyłanie plików za nas.

 yarn add express cors multer

Następnie dodajemy skrypt src/server.js , który konfiguruje serwer Express za pomocą cors i multer oraz udostępnia punkt końcowy /upload , do którego prześlemy obraz.

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

Teraz, gdy mamy już konfigurację serwera, możemy skupić się na obsłudze przesyłania obrazu. Kiedy użytkownik przesyła obraz, może minąć kilka sekund, zanim obraz zostanie przesłany i mamy do niego adres URL. Robimy jednak wszystko, aby natychmiast przekazać użytkownikowi informację, że trwa przesyłanie obrazu, aby wiedział, że obraz jest wstawiany do edytora. Oto kroki, które wdrażamy, aby to zachowanie działało -

  1. Gdy użytkownik wybierze obraz, wstawiamy węzeł obrazu w pozycji kursora użytkownika z ustawioną flagą isUploading , dzięki czemu możemy pokazać użytkownikowi stan ładowania.
  2. Wysyłamy żądanie do serwera, aby przesłać obraz.
  3. Gdy żądanie jest kompletne i mamy adres URL obrazu, ustawiamy go na obrazie i usuwamy stan ładowania.

Zacznijmy od pierwszego kroku, w którym wstawiamy węzeł obrazu. Teraz najtrudniejsze jest to, że napotykamy ten sam problem z wyborem, co w przypadku przycisku linku na pasku narzędzi. Gdy tylko użytkownik kliknie przycisk Obraz na pasku narzędzi, edytor przestaje być aktywny, a zaznaczenie staje się null . Jeśli spróbujemy wstawić obraz, nie wiemy, gdzie był kursor użytkownika. Śledzenie previousSelection podaje nam tę lokalizację i używamy jej do wstawiania węzła.

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

Gdy wstawiamy nowy węzeł obrazu, przypisujemy mu również identyfikator id za pomocą pakietu uuid. W implementacji kroku (3) omówimy, dlaczego tego potrzebujemy. Teraz aktualizujemy komponent obrazu, aby używał flagi isUploading do pokazywania stanu ładowania.

 {!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}

To kończy implementację kroku 1. Sprawdźmy, czy jesteśmy w stanie wybrać obraz do przesłania, zobaczmy wstawiany węzeł obrazu ze wskaźnikiem ładowania w miejscu, w którym został wstawiony w dokumencie.

Przesyłanie obrazu tworząc węzeł obrazu ze stanem ładowania.

Przechodząc do kroku (2), użyjemy biblioteki axois do wysłania żądania do serwera.

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

Weryfikujemy, czy przesyłanie obrazu działa, a obraz pojawia się w folderze public/photos aplikacji. Teraz, gdy przesyłanie obrazu jest zakończone, przechodzimy do kroku (3), w którym chcemy ustawić adres URL obrazu w funkcji resolve resolve() obietnicy axios. Moglibyśmy zaktualizować obraz za pomocą Transforms.setNodes , ale mamy problem — nie mamy ścieżki do nowo wstawionego węzła obrazu. Zobaczmy, jakie są nasze opcje, aby dostać się do tego obrazu —

  • Czy nie możemy użyć editor.selection , ponieważ zaznaczenie musi znajdować się na nowo wstawionym węźle obrazu? Nie możemy tego zagwarantować, ponieważ podczas przesyłania obrazu użytkownik mógł kliknąć w innym miejscu i wybór mógł się zmienić.
  • Co powiesz na użycie previousSelection , którego użyliśmy do wstawienia węzła obrazu w pierwszej kolejności? Z tego samego powodu, dla którego nie możemy użyć editor.selection , nie możemy użyć previousSelection , ponieważ mogło się to również zmienić.
  • SlateJS posiada moduł History, który śledzi wszystkie zmiany zachodzące w dokumencie. Moglibyśmy użyć tego modułu do przeszukania historii i znalezienia ostatniego wstawionego węzła obrazu. Nie jest to również całkowicie niezawodne, jeśli przesłanie obrazu trwało dłużej, a użytkownik wstawił więcej obrazów w różnych częściach dokumentu przed zakończeniem pierwszego przesyłania.
  • Obecnie interfejs API Transform.insertNodes nie zwraca żadnych informacji o wstawionych węzłach. Gdyby mógł zwrócić ścieżki do wstawionych węzłów, moglibyśmy użyć tego do znalezienia dokładnego węzła obrazu, który powinniśmy zaktualizować.

Ponieważ żadne z powyższych podejść nie działa, stosujemy id do węzła wstawionego obrazu (w kroku (1)) i ponownie używamy tego samego id , aby zlokalizować go po zakończeniu przesyłania obrazu. Dzięki temu nasz kod dla kroku (3) wygląda jak poniżej —

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

Po wykonaniu wszystkich trzech kroków jesteśmy gotowi do przetestowania przesyłania obrazu od początku do końca.

Przesyłanie obrazu działa od końca do końca

W ten sposób przygotowaliśmy obrazy dla naszego edytora. Obecnie pokazujemy stan ładowania o tym samym rozmiarze, niezależnie od obrazu. Może to być irytujące dla użytkownika, jeśli stan ładowania zostanie zastąpiony drastycznie mniejszym lub większym obrazem po zakończeniu przesyłania. Dobrą kontynuacją procesu przesyłania jest pobranie wymiarów obrazu przed przesłaniem i pokazanie symbolu zastępczego tego rozmiaru, aby przejście było płynne. Hak, który dodaliśmy powyżej, może zostać rozszerzony o obsługę innych typów mediów, takich jak wideo lub dokumenty, a także renderowanie tych typów węzłów.

Wniosek

W tym artykule zbudowaliśmy edytor WYSIWYG, który ma podstawowy zestaw funkcji i kilka mikro-doświadczeń użytkownika, takich jak wykrywanie linków, edycja linków w miejscu i edycja podpisów graficznych, które pomogły nam zagłębić się w SlateJS i koncepcje edycji tekstu sformatowanego w ogólny. Jeśli interesuje Cię problematyka związana z edycją tekstu sformatowanego lub przetwarzaniem tekstu, niektóre z fajnych problemów, którymi możesz się zająć, mogą być następujące:

  • Współpraca
  • Bogatsze środowisko edycji tekstu, które obsługuje wyrównanie tekstu, obrazy w tekście, kopiowanie i wklejanie, zmianę czcionek i kolorów tekstu itp.
  • Importowanie z popularnych formatów, takich jak dokumenty Word i Markdown.

Jeśli chcesz dowiedzieć się więcej o SlateJS, oto kilka linków, które mogą być pomocne.

  • Przykłady SlateJS
    Wiele przykładów, które wykraczają poza podstawy i budują funkcje, które zwykle można znaleźć w edytorach, takich jak Wyszukaj i wyróżnij, Podgląd Markdown i Wzmianki.
  • Dokumenty API
    Odniesienie do wielu funkcji pomocniczych udostępnianych przez SlateJS, które warto mieć pod ręką podczas wykonywania złożonych zapytań/transformacji na obiektach SlateJS.

Wreszcie, Slack Channel SlateJS to bardzo aktywna społeczność twórców stron internetowych tworzących aplikacje do edycji tekstu sformatowanego przy użyciu SlateJS i świetne miejsce, aby dowiedzieć się więcej o bibliotece i uzyskać pomoc w razie potrzeby.