Tworzenie edytora tekstu sformatowanego (WYSIWYG)
Opublikowany: 2022-03-10W 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.
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.
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 icaption
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 (zwanychanchor
ifocus
), które reprezentują zakres tekstu wewnątrz dokumentu. Ta koncepcja pochodzi z interfejsu API wyboru sieci Web, w którymanchor
to miejsce, w którym rozpoczyna się wybór użytkownika, afocus
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
:
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
z dodanymi do niej zależnościami SlateJS. UI aplikacji budujemy przy użyciu komponentów z create-react-app
. Zacznijmy!react-bootstrap
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 tylkodiv
. - Zaktualizuj komponent
App.js
, aby utrzymywał dokument w stanie, który jest zainicjowany w naszymExampleDocument
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ł
na tym etapie wygląda tak, jak poniżej: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> ); }
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.
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
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 }, ], },
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.
Oto trzy kluczowe funkcje, które potrzebujemy do obsługi paska narzędzi:
- 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.
- Kiedy użytkownik wybierze zakres tekstu i kliknie jeden z przycisków stylu znaków, musimy przełączyć styl dla tej konkretnej sekcji.
- 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ć.
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 <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} />
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 toeditor.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 poziomuat
danej lokalizacji dopasowującej funkcjęmatch
. Ten parametr (ustawiony nahighest
) 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> .... );
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 }, ], }, ... }
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>
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
.
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.
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", }); ...
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ć.
Kroki logiki umożliwiające to zachowanie to:
- 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.
- 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.
- 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ą. Przyjmujeunit
jako parametr, więc możemy poprosić o znak/słowo/blok itp. przed przekazaniemlocation
. -
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.
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
.
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.
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.
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 (patrzExampleDocument
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: "" }], }, ];
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.
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 -
- 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. - Wysyłamy żądanie do serwera, aby przesłać obraz.
- 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.
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.
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.