Creazione di un editor di testo avanzato (WYSIWYG)

Pubblicato: 2022-03-10
Riepilogo rapido ↬ In questo articolo impareremo come creare un editor WYSIWYG/Rich-Text che supporti rich text, immagini, collegamenti e alcune funzionalità sfumate delle app di elaborazione testi. Useremo SlateJS per costruire la shell dell'editor e quindi aggiungere una barra degli strumenti e configurazioni personalizzate. Il codice per l'applicazione è disponibile su GitHub come riferimento.

Negli ultimi anni, il campo della creazione e rappresentazione di contenuti su piattaforme digitali ha subito una massiccia interruzione. Il successo diffuso di prodotti come Quip, Google Docs e Dropbox Paper ha mostrato come le aziende stiano correndo per creare la migliore esperienza per i creatori di contenuti nel dominio aziendale e cercando di trovare modi innovativi per rompere gli schemi tradizionali di condivisione e consumo dei contenuti. Approfittando della massiccia diffusione delle piattaforme di social media, c'è una nuova ondata di creatori di contenuti indipendenti che utilizzano piattaforme come Medium per creare contenuti e condividerli con il loro pubblico.

Poiché così tante persone di diverse professioni e background cercano di creare contenuti su questi prodotti, è importante che questi prodotti offrano un'esperienza di creazione di contenuti efficiente e senza interruzioni e abbiano team di designer e ingegneri che sviluppano nel tempo un certo livello di esperienza nel settore in questo spazio . Con questo articolo, cerchiamo non solo di gettare le basi per la creazione di un editor, ma anche di dare ai lettori un'idea di come piccole pepite di funzionalità, se unite insieme, possono creare un'esperienza utente eccezionale per un creatore di contenuti.

Comprendere la struttura del documento

Prima di addentrarci nella creazione dell'editor, diamo un'occhiata a come è strutturato un documento per un Rich Text Editor e quali sono i diversi tipi di strutture dati coinvolte.

Nodi del documento

I nodi del documento vengono utilizzati per rappresentare il contenuto del documento. I tipi comuni di nodi che un documento rich-text potrebbe contenere sono paragrafi, intestazioni, immagini, video, blocchi di codice e virgolette. Alcuni di questi possono contenere altri nodi come figli al loro interno (ad es. i nodi Paragrafo contengono nodi di testo al loro interno). I nodi contengono anche tutte le proprietà specifiche dell'oggetto che rappresentano che sono necessarie per eseguire il rendering di quei nodi all'interno dell'editor. (ad es. i nodi immagine contengono una proprietà src dell'immagine, i blocchi di codice possono contenere una proprietà del language e così via).

Esistono principalmente due tipi di nodi che rappresentano come dovrebbero essere renderizzati:

  • Nodi a blocchi (analoghi al concetto HTML di elementi a livello di blocco) che vengono renderizzati ciascuno su una nuova riga e occupano la larghezza disponibile. I nodi a blocchi possono contenere altri nodi a blocchi o nodi inline al loro interno. Un'osservazione qui è che i nodi di primo livello di un documento sarebbero sempre nodi di blocco.
  • Nodi Inline (analoghi al concetto HTML di elementi Inline) che iniziano il rendering sulla stessa riga del nodo precedente. Ci sono alcune differenze nel modo in cui gli elementi inline sono rappresentati in diverse librerie di editing. SlateJS consente agli elementi inline di essere nodi stessi. DraftJS, un'altra popolare libreria di Rich Text Editing, ti consente di utilizzare il concetto di Entità per eseguire il rendering di elementi inline. I collegamenti e le immagini in linea sono esempi di nodi in linea.
  • Nodi vuoti — SlateJS consente anche questa terza categoria di nodi che useremo più avanti in questo articolo per il rendering dei media.

Se vuoi saperne di più su queste categorie, la documentazione di SlateJS sui nodi è un buon punto di partenza.

Altro dopo il salto! Continua a leggere sotto ↓

Attributi

Simile al concetto di attributi dell'HTML, gli attributi in un documento di testo RTF vengono utilizzati per rappresentare le proprietà non di contenuto di un nodo o dei suoi figli. Ad esempio, un nodo di testo può avere attributi di stile del carattere che ci dicono se il testo è in grassetto/corsivo/sottolineato e così via. Sebbene questo articolo rappresenti i titoli come nodi stessi, un altro modo per rappresentarli potrebbe essere che i nodi abbiano stili di paragrafo ( paragraph e h1-h6 ) come attributi su di essi.

L'immagine seguente fornisce un esempio di come la struttura di un documento (in JSON) viene descritta a un livello più granulare utilizzando nodi e attributi che evidenziano alcuni degli elementi nella struttura a sinistra.

Immagine che mostra un documento di esempio all'interno dell'editor con la sua rappresentazione della struttura a sinistra
Esempio di documento e sua rappresentazione strutturale. (Grande anteprima)

Alcune delle cose che vale la pena chiamare qui con la struttura sono:

  • I nodi di testo sono rappresentati come {text: 'text content'}
  • Le proprietà dei nodi vengono salvate direttamente sul nodo (es. url per link e caption per immagini)
  • La rappresentazione specifica di SlateJS degli attributi di testo suddivide i nodi di testo in nodi propri se lo stile del carattere cambia. Quindi, il testo " Duis aute irure dolor " è un nodo di testo a sé stante con bold: true impostato su di esso. Lo stesso vale per il testo in corsivo, sottolineato e in stile codice in questo documento.

Posizioni E Selezione

Quando si crea un rich text editor, è fondamentale comprendere come la parte più granulare di un documento (ad esempio un carattere) può essere rappresentata con una sorta di coordinate. Questo ci aiuta a navigare nella struttura del documento in fase di esecuzione per capire dove ci troviamo nella gerarchia dei documenti. Ancora più importante, gli oggetti posizione ci danno un modo per rappresentare la selezione dell'utente che è ampiamente utilizzato per personalizzare l'esperienza dell'utente dell'editor in tempo reale. Useremo la selezione per costruire la nostra barra degli strumenti più avanti in questo articolo. Esempi di questi potrebbero essere:

  • Il cursore dell'utente è attualmente all'interno di un collegamento, forse dovremmo mostrare loro un menu per modificare/rimuovere il collegamento?
  • L'utente ha selezionato un'immagine? Forse diamo loro un menu per ridimensionare l'immagine.
  • Se l'utente seleziona un determinato testo e preme il pulsante DELETE, determiniamo quale era il testo selezionato dall'utente e lo rimuoviamo dal documento.

Il documento di SlateJS sulla posizione spiega ampiamente queste strutture di dati, ma le esaminiamo qui rapidamente poiché utilizziamo questi termini in diverse istanze nell'articolo e mostriamo un esempio nel diagramma che segue.

  • Sentiero
    Rappresentato da una matrice di numeri, un percorso è il modo per raggiungere un nodo nel documento. Ad esempio, un percorso [2,3] rappresenta il 3° nodo figlio del 2° nodo nel documento.
  • Punto
    Posizione più granulare del contenuto rappresentata da percorso + offset. Ad esempio, un punto di {path: [2,3], offset: 14} rappresenta il 14° carattere del 3° nodo figlio all'interno del 2° nodo del documento.
  • Allineare
    Una coppia di punti (chiamati anchor e focus ) che rappresentano un intervallo di testo all'interno del documento. Questo concetto deriva dall'API di selezione del Web in cui anchor è il punto in cui è iniziata la selezione dell'utente e il focus è dove è terminata. Un intervallo/selezione compresso indica dove i punti di ancoraggio e focus sono gli stessi (pensa ad esempio a un cursore lampeggiante in un input di testo).

Ad esempio, diciamo che la selezione dell'utente nel nostro esempio di documento sopra è ipsum :

Immagine con il testo ` ipsum` selezionato nell'editor
L'utente seleziona la parola ipsum . (Grande anteprima)

La selezione dell'utente può essere rappresentata come:

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

Configurazione dell'editor

In questa sezione, configureremo l'applicazione e otterremo un editor di testo RTF di base con SlateJS. L'applicazione standard sarebbe create-react-app con le dipendenze SlateJS aggiunte ad essa. Stiamo costruendo l'interfaccia utente dell'applicazione utilizzando i componenti di react-bootstrap . Iniziamo!

Crea una cartella chiamata wysiwyg-editor ed esegui il comando seguente dall'interno della directory per configurare l'app di reazione. Quindi eseguiamo un comando yarn start che dovrebbe avviare il server Web locale (porta predefinita su 3000) e mostrarti una schermata di benvenuto di React.

 npx create-react-app . yarn start

Si passa quindi all'aggiunta delle dipendenze SlateJS all'applicazione.

 yarn add slate slate-react

slate è il pacchetto principale di SlateJS e slate-react include l'insieme di componenti React che utilizzeremo per il rendering degli editor Slate. SlateJS espone alcuni altri pacchetti organizzati per funzionalità che si potrebbero considerare di aggiungere al proprio editor.

Per prima cosa creiamo una cartella utils che contiene tutti i moduli di utilità che creiamo in questa applicazione. Iniziamo con la creazione di un ExampleDocument.js che restituisce una struttura di base del documento che contiene un paragrafo con del testo. Questo modulo è simile al seguente:

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

Ora aggiungiamo una cartella chiamata components che conterrà tutti i nostri componenti React e facciamo quanto segue:

  • Aggiungi il nostro primo componente React Editor.js ad esso. Restituisce solo un div per ora.
  • Aggiorna il componente App.js per mantenere il documento nel suo stato che è stato inizializzato nel nostro ExampleDocument sopra.
  • Eseguire il rendering dell'editor all'interno dell'app e passare lo stato del documento e un gestore onChange all'editor in modo che lo stato del nostro documento venga aggiornato man mano che l'utente lo aggiorna.
  • Utilizziamo anche i componenti Nav di React bootstrap per aggiungere una barra di navigazione all'applicazione.

Il componente App.js ora appare come di seguito:

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

All'interno del componente Editor, creiamo quindi un'istanza dell'editor SlateJS e lo teniamo all'interno di un useMemo in modo che l'oggetto non cambi tra un rendering e l'altro.

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

createEditor ci fornisce l'istanza editor SlateJS che utilizziamo ampiamente tramite l'applicazione per accedere alle selezioni, eseguire trasformazioni di dati e così via. withReact è un plugin SlateJS che aggiunge comportamenti React e DOM all'oggetto editor. I plugin SlateJS sono funzioni Javascript che ricevono l'oggetto editor e vi allegano una configurazione. Ciò consente agli sviluppatori Web di aggiungere configurazioni alla propria istanza dell'editor SlateJS in modo componibile.

Ora importiamo ed eseguiamo il rendering di componenti <Slate /> e <Editable /> da SlateJS con il prop del documento che otteniamo da App.js. Slate espone una serie di contesti React che utilizziamo per accedere al codice dell'applicazione. Editable è il componente che esegue il rendering della gerarchia del documento per la modifica. Nel complesso, il modulo Editor.js in questa fase è simile al seguente:

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

A questo punto, abbiamo aggiunto i componenti React necessari e l'editor popolato con un documento di esempio. Il nostro Editor dovrebbe ora essere configurato permettendoci di digitare e modificare il contenuto in tempo reale, come nello screencast qui sotto.

Configurazione di base dell'editor in azione

Ora, passiamo alla sezione successiva in cui configuriamo l'editor per il rendering di stili di carattere e nodi di paragrafo.

RENDERING DI TESTO PERSONALIZZATO E UNA BARRA DEGLI STRUMENTI

Nodi di stile paragrafo

Attualmente, il nostro editor utilizza il rendering predefinito di SlateJS per qualsiasi nuovo tipo di nodo che possiamo aggiungere al documento. In questa sezione, vogliamo essere in grado di eseguire il rendering dei nodi di intestazione. Per poterlo fare, forniamo una funzione renderElement prop ai componenti di Slate. Questa funzione viene chiamata da Slate in fase di esecuzione quando tenta di attraversare l'albero dei documenti e di eseguire il rendering di ogni nodo. La funzione renderElement ottiene tre parametri:

  • attributes
    Specifico di SlateJS che deve essere applicato all'elemento DOM di primo livello restituito da questa funzione.
  • element
    L'oggetto nodo stesso così com'è nella struttura del documento
  • children
    I figli di questo nodo come definito nella struttura del documento.

Aggiungiamo la nostra implementazione renderElement a un hook chiamato useEditorConfig dove aggiungeremo più configurazioni dell'editor man mano che procediamo. Usiamo quindi l'hook sull'istanza dell'editor all'interno 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} />; } }

Poiché questa funzione ci dà accesso element (che è il nodo stesso), possiamo personalizzare renderElement per implementare un rendering più personalizzato che fa molto di più che controllare element.type . Ad esempio, potresti avere un nodo immagine che ha una proprietà isInline che potremmo usare per restituire una struttura DOM diversa che ci aiuta a eseguire il rendering di immagini inline rispetto alle immagini a blocchi.

Ora aggiorniamo il componente Editor per utilizzare questo hook come di seguito:

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

Con il rendering personalizzato in atto, aggiorniamo ExampleDocument per includere i nostri nuovi tipi di nodi e verifichiamo che vengano visualizzati correttamente all'interno dell'editor.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
Immagine che mostra diverse intestazioni e nodi di paragrafo renderizzati nell'editor
Intestazioni e nodi Paragrafo nell'Editor. (Grande anteprima)

Stili di carattere

Simile a renderElement , SlateJS fornisce una funzione prop chiamata renderLeaf che può essere utilizzata per personalizzare il rendering dei nodi di testo (Lea si riferisce ai nodi di testo che sono le Leaf /nodi di livello più basso dell'albero del documento). Seguendo l'esempio di renderElement , scriviamo un'implementazione per 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>; }

Un'osservazione importante dell'implementazione di cui sopra è che ci consente di rispettare la semantica HTML per gli stili di carattere. Poiché renderLeaf ci dà accesso alla leaf del nodo di testo stessa, possiamo personalizzare la funzione per implementare un rendering più personalizzato. Ad esempio, potresti avere un modo per consentire agli utenti di scegliere un highlightColor per il testo e controllare la proprietà della foglia qui per allegare i rispettivi stili.

Ora aggiorniamo il componente Editor per utilizzare quanto sopra, ExampleDocument per avere alcuni nodi di testo nel paragrafo con combinazioni di questi stili e verifichiamo che siano renderizzati come previsto nell'Editor con i tag semantici che abbiamo usato.

 # 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 }, ], }, 
Stili di carattere nell'interfaccia utente e come vengono visualizzati nell'albero DOM
Stili di carattere nell'interfaccia utente e come vengono visualizzati nell'albero DOM. (Grande anteprima)

Aggiunta di una barra degli strumenti

Iniziamo aggiungendo un nuovo componente Toolbar.js a cui aggiungiamo alcuni pulsanti per gli stili di carattere e un menu a discesa per gli stili di paragrafo e li colleghiamo più avanti nella sezione.

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

Astraiamo i pulsanti al componente ToolbarButton che è un wrapper attorno al componente React Bootstrap Button. Quindi eseguiamo il rendering della barra degli strumenti sopra il componente Editable all'interno Editor e verifichiamo che la barra degli strumenti venga visualizzata nell'applicazione.

Immagine che mostra la barra degli strumenti con i pulsanti renderizzati sopra l'editor
Barra degli strumenti con pulsanti (anteprima grande)

Ecco le tre funzionalità chiave che la barra degli strumenti deve supportare:

  1. Quando il cursore dell'utente si trova in un determinato punto del documento e fa clic su uno dei pulsanti dello stile del carattere, è necessario attivare o disattivare lo stile per il testo che può digitare successivamente.
  2. Quando l'utente seleziona un intervallo di testo e fa clic su uno dei pulsanti dello stile del carattere, è necessario attivare o disattivare lo stile per quella sezione specifica.
  3. Quando l'utente seleziona un intervallo di testo, vogliamo aggiornare il menu a discesa dello stile di paragrafo per riflettere il tipo di paragrafo della selezione. Se selezionano un valore diverso dalla selezione, vogliamo aggiornare lo stile di paragrafo dell'intera selezione in modo che sia quello che hanno selezionato.

Diamo un'occhiata a come funzionano queste funzionalità nell'Editor prima di iniziare a implementarle.

Comportamento di attivazione/disattivazione degli stili di carattere

Ascoltando la selezione

La cosa più importante che la barra degli strumenti ha bisogno per essere in grado di eseguire le funzioni di cui sopra è lo stato di selezione del documento. Al momento della stesura di questo articolo, SlateJS non espone un metodo onSelectionChange che potrebbe fornirci l'ultimo stato di selezione del documento. Tuttavia, poiché la selezione cambia nell'editor, SlateJS chiama il metodo onChange , anche se il contenuto del documento non è cambiato. Lo usiamo come un modo per essere avvisati del cambio di selezione e salvarlo nello stato del componente Editor . Astraiamo questo in un hook useSelection in cui potremmo eseguire un aggiornamento più ottimale dello stato di selezione. Questo è importante poiché la selezione è una proprietà che cambia abbastanza spesso per un'istanza dell'editor 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]; }

Usiamo questo hook all'interno del componente Editor come di seguito e passiamo la selezione al componente 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} /> ...

Considerazione sulle prestazioni

In un'applicazione in cui abbiamo una base di codice Editor molto più grande con molte più funzionalità, è importante archiviare e ascoltare le modifiche alla selezione in modo efficiente (come l'utilizzo di alcune librerie di gestione dello stato) poiché è probabile che anche i componenti che ascoltano le modifiche alla selezione vengano visualizzati Spesso. Un modo per farlo è avere selettori ottimizzati sopra lo stato Selezione che contengono informazioni di selezione specifiche. Ad esempio, un editor potrebbe voler eseguire il rendering di un menu di ridimensionamento dell'immagine quando viene selezionata un'immagine. In tal caso, potrebbe essere utile avere un selettore isImageSelected calcolato dallo stato di selezione dell'editor e il menu Immagine verrà riprodotto solo quando il valore di questo selettore cambia. Reselect di Redux è una di queste librerie che abilita la creazione di selettori.

Non usiamo la selection all'interno della barra degli strumenti fino a un secondo momento, ma passandola come oggetto di supporto la barra degli strumenti viene riprodotta ogni volta che la selezione cambia nell'Editor. Lo facciamo perché non possiamo fare affidamento esclusivamente sulla modifica del contenuto del documento per attivare un nuovo rendering sulla gerarchia ( App -> Editor -> Toolbar degli strumenti) poiché gli utenti potrebbero semplicemente continuare a fare clic sul documento modificando così la selezione ma non cambiando mai effettivamente il contenuto del documento si.

Commutazione degli stili di carattere

Passiamo ora a ottenere quali sono gli stili di carattere attivi da SlateJS e utilizzare quelli all'interno dell'Editor. Aggiungiamo un nuovo modulo JS EditorUtils che ospiterà tutte le funzioni di utilità che creiamo in futuro per ottenere/fare cose con SlateJS. La nostra prima funzione nel modulo è getActiveStyles che fornisce un Set di stili attivi nell'editor. Aggiungiamo anche una funzione per attivare o disattivare uno stile nella funzione dell'editor — 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); } }

Entrambe le funzioni prendono l'oggetto editor che è l'istanza Slate come parametro, così come molte funzioni utili che aggiungeremo più avanti nell'articolo. Nella terminologia di Slate, gli stili di formattazione sono chiamati Marks e utilizziamo metodi di supporto sull'interfaccia Editor per ottenere, aggiungere e rimuoviamo questi segni. Importiamo queste funzioni utili all'interno della barra degli strumenti e le colleghiamo ai pulsanti che abbiamo aggiunto in precedenza.

 # 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 è un hook Slate che ci dà accesso all'istanza Slate dal contesto in cui era collegata dal componente &lt;Slate> più in alto nella gerarchia di rendering.

Ci si potrebbe chiedere perché qui utilizziamo onMouseDown invece di onClick ? C'è un problema Github aperto su come Slate trasforma la selection su null quando l'editor perde lo stato attivo in qualsiasi modo. Quindi, se colleghiamo i gestori onClick ai pulsanti della nostra barra degli strumenti, la selection diventa null e gli utenti perdono la posizione del cursore cercando di attivare uno stile che non è un'esperienza eccezionale. Al contrario, modifichiamo lo stile allegando un evento onMouseDown che impedisce il ripristino della selezione. Un altro modo per farlo è tenere traccia della selezione in modo da sapere qual è stata l'ultima selezione e utilizzarla per alternare gli stili. Introduciamo il concetto di selezione previousSelection più avanti nell'articolo, ma per risolvere un problema diverso.

SlateJS ci consente di configurare i gestori di eventi sull'editor. Lo usiamo per collegare le scorciatoie da tastiera per alternare gli stili dei caratteri. Per fare ciò, aggiungiamo un oggetto KeyBindings all'interno useEditorConfig dove esponiamo un gestore di eventi onKeyDown collegato al componente Editable . Usiamo l'utility is-hotkey per determinare la combinazione di tasti e alternare lo stile corrispondente.

 # 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} /> 
Stili di carattere alternati utilizzando le scorciatoie da tastiera.

Far funzionare il menu a discesa dello stile di paragrafo

Passiamo a far funzionare il menu a discesa Stili di paragrafo. Simile a come funzionano i menu a discesa in stile paragrafo nelle applicazioni di elaborazione testi più diffuse come MS Word o Google Docs, vogliamo che gli stili dei blocchi di livello superiore nella selezione dell'utente si riflettano nel menu a discesa. Se è presente un unico stile coerente nella selezione, aggiorniamo il valore del menu a discesa in modo che sia quello. Se ce ne sono più, impostiamo il valore del menu a discesa su "Multiplo". Questo comportamento deve funzionare sia per le selezioni compresse che per quelle espanse.

Per implementare questo comportamento, dobbiamo essere in grado di trovare i blocchi di primo livello che coprono la selezione dell'utente. Per fare ciò, utilizziamo Editor.nodes di Slate — Una funzione di supporto comunemente usata per cercare i nodi in un albero filtrato da diverse opzioni.

 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>

La funzione di supporto accetta un'istanza Editor e un oggetto options che è un modo per filtrare i nodi nell'albero mentre lo attraversa. La funzione restituisce un generatore di NodeEntry . Un NodeEntry nella terminologia di Slate è una tupla di un nodo e il percorso ad esso — [node, pathToNode] . Le opzioni trovate qui sono disponibili sulla maggior parte delle funzioni di supporto di Slate. Esaminiamo cosa significa ciascuno di questi:

  • at
    Questo può essere un Percorso/Punto/Intervallo che la funzione di supporto utilizzerebbe per l'ambito dell'attraversamento dell'albero. Il valore predefinito editor.selection se non fornito. Utilizziamo anche l'impostazione predefinita per il nostro caso d'uso di seguito poiché siamo interessati ai nodi all'interno della selezione dell'utente.
  • match
    Questa è una funzione di corrispondenza che è possibile fornire che viene chiamata su ciascun nodo e inclusa se si tratta di una corrispondenza. Usiamo questo parametro nella nostra implementazione di seguito per filtrare per bloccare solo gli elementi.
  • mode
    Facciamo sapere alle funzioni di supporto se siamo interessati a tutti, i nodi at data funzione di match della corrispondenza della posizione. Questo parametro (impostato su highest ) ci aiuta a scappare cercando di attraversare l'albero noi stessi per trovare i nodi di livello superiore.
  • universal
    Flag per scegliere tra corrispondenze totali o parziali dei nodi. (Il problema con GitHub con la proposta per questo flag ha alcuni esempi che lo spiegano)
  • reverse
    Se la ricerca del nodo deve essere nella direzione inversa dei punti di inizio e di fine della posizione passata.
  • voids
    Se la ricerca deve filtrare per annullare solo gli elementi.

SlateJS espone molte funzioni di supporto che consentono di eseguire query sui nodi in modi diversi, attraversare l'albero, aggiornare i nodi o le selezioni in modi complessi. Vale la pena approfondire alcune di queste interfacce (elencate verso la fine di questo articolo) quando si creano complesse funzionalità di modifica su Slate.

Con questo background sulla funzione di supporto, di seguito è riportata un'implementazione di 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; }

Considerazione sulle prestazioni

L'attuale implementazione di Editor.nodes trova tutti i nodi nell'albero in tutti i livelli che sono all'interno dell'intervallo del parametro at e quindi esegue filtri di corrispondenza su di esso (controlla nodeEntries e il filtro in seguito — sorgente). Questo va bene per i documenti più piccoli. Tuttavia, per il nostro caso d'uso, se l'utente ha selezionato, ad esempio 3 intestazioni e 2 paragrafi (ogni paragrafo contiene ad esempio 10 nodi di testo), passerà in rassegna almeno 25 nodi (3 + 2 + 2*10) e proverà a eseguire filtri su di essi. Poiché sappiamo già che siamo interessati solo ai nodi di livello superiore, potremmo trovare gli indici di inizio e fine dei blocchi di livello superiore dalla selezione e ripetere noi stessi. Una tale logica scorrerebbe attraverso solo 3 voci di nodo (2 intestazioni e 1 paragrafo). Il codice per questo sarebbe simile al seguente:

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

Poiché aggiungiamo più funzionalità a un editor WYSIWYG e dobbiamo attraversare spesso l'albero del documento, è importante pensare ai modi più efficaci per farlo per il caso d'uso in questione poiché l'API disponibile o i metodi di supporto potrebbero non essere sempre i più modo efficiente per farlo.

Una volta implementato getTextBlockStyle , l'attivazione/disattivazione dello stile del blocco è relativamente semplice. Se lo stile corrente non è quello che l'utente ha selezionato nel menu a discesa, passiamo allo stile in quello. Se è già ciò che l'utente ha selezionato, lo trasformiamo in un paragrafo. Poiché rappresentiamo gli stili di paragrafo come nodi nella nostra struttura del documento, attivare o disattivare uno stile di paragrafo significa essenzialmente modificare la proprietà del type sul nodo. Usiamo Transforms.setNodes fornito da Slate per aggiornare le proprietà sui nodi.

La nostra implementazione di toggleBlockType è la seguente:

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

Infine, aggiorniamo il nostro menu a discesa Stile paragrafo per utilizzare queste funzioni di utilità.

 #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> .... ); 
Selezione di più tipi di blocco e modifica del tipo con il menu a discesa.

LINK

In questa sezione, aggiungeremo il supporto per mostrare, aggiungere, rimuovere e modificare i collegamenti. Aggiungeremo anche una funzionalità Link-Detector, abbastanza simile a come Google Docs o MS Word che scansionano il testo digitato dall'utente e controllano se sono presenti collegamenti. Se ci sono, vengono convertiti in oggetti collegamento in modo che l'utente non debba utilizzare i pulsanti della barra degli strumenti per farlo da solo.

Collegamenti di rendering

Nel nostro editor implementeremo i collegamenti come nodi inline con SlateJS. Aggiorniamo la nostra configurazione dell'editor per contrassegnare i collegamenti come nodi inline per SlateJS e forniamo anche un componente per il rendering in modo che Slate sappia come eseguire il rendering dei nodi di collegamento.

 # 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. (Grande anteprima)

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>

Dato che stiamo visualizzando il LinkEditor al di fuori dell'editor, abbiamo bisogno di un modo per dire a LinkEditor dove si trova il collegamento nell'albero DOM in modo che possa essere visualizzato vicino all'editor. Il modo in cui lo facciamo è utilizzare l'API React di Slate per trovare il nodo DOM corrispondente al nodo di collegamento nella selezione. E quindi utilizziamo getBoundingClientRect() per trovare i limiti dell'elemento DOM del collegamento e i limiti del componente dell'editor e calcolare la parte top e left per l'editor del collegamento. Gli aggiornamenti del codice per Editor e LinkEditor sono i seguenti:

 # 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 mantiene internamente le mappe dei nodi ai rispettivi elementi DOM. Accediamo a quella mappa e troviamo l'elemento DOM del collegamento utilizzando ReactEditor.toDOMNode .

La selezione all'interno di un collegamento mostra il popover dell'editor di collegamento.

Come si vede nel video sopra, quando un link è inserito e non ha un URL, poiché la selezione è all'interno del link, si apre l'editor dei link dando così all'utente la possibilità di digitare un URL per il link appena inserito e quindi chiude il ciclo sull'esperienza dell'utente lì.

Ora aggiungiamo un elemento di input e un pulsante al LinkEditor che consente all'utente di digitare un URL e applicarlo al nodo di collegamento. Usiamo il pacchetto isUrl per la convalida dell'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> ... );

Con gli elementi del modulo collegati, vediamo se l'editor di link funziona come previsto.

Editor che perde la selezione facendo clic all'interno dell'editor di collegamenti

Come vediamo qui nel video, quando l'utente tenta di fare clic sull'input, l'editor di collegamento scompare. Questo perché mentre rendiamo l'editor di link al di fuori del componente Editable , quando l'utente fa clic sull'elemento di input, SlateJS pensa che l'editor abbia perso lo stato attivo e reimposta la selection su null , il che rimuove il LinkEditor poiché isLinkActiveAtSelection non è più true . Esiste un problema GitHub aperto che parla di questo comportamento di Slate. Un modo per risolvere questo problema è tenere traccia della selezione precedente di un utente mentre cambia e quando l'editor perde lo stato attivo, potremmo guardare la selezione precedente e mostrare comunque un menu dell'editor di collegamenti se la selezione precedente conteneva un collegamento. Aggiorniamo l'hook useSelection per ricordare la selezione precedente e restituirla al componente 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]; }

Quindi aggiorniamo la logica nel componente Editor per mostrare il menu di collegamento anche se la selezione precedente conteneva un collegamento.

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

Quindi aggiorniamo LinkEditor per usare selectionForLink per cercare il nodo di collegamento, renderizzare sotto di esso e aggiornare il suo URL.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
Modifica del collegamento utilizzando il componente LinkEditor.

Rilevamento di collegamenti nel testo

La maggior parte delle applicazioni di elaborazione testi identifica e converte i collegamenti all'interno del testo per collegare gli oggetti. Vediamo come funzionerebbe nell'editor prima di iniziare a costruirlo.

Collegamenti rilevati mentre l'utente li digita.

I passaggi della logica per abilitare questo comportamento sarebbero:

  1. Man mano che il documento cambia con la digitazione dell'utente, trova l'ultimo carattere inserito dall'utente. Se quel carattere è uno spazio, sappiamo che deve esserci una parola che potrebbe essere venuta prima di esso.
  2. Se l'ultimo carattere era lo spazio, lo contrassegniamo come limite finale della parola che lo precede. Quindi torniamo indietro carattere per carattere all'interno del nodo di testo per trovare dove iniziava quella parola. Durante questa traversata, dobbiamo stare attenti a non oltrepassare il bordo dell'inizio del nodo nel nodo precedente.
  3. Dopo aver trovato i limiti di inizio e fine della parola prima, controlliamo la stringa della parola e vediamo se si trattava di un URL. Se lo fosse, lo convertiamo in un nodo di collegamento.

La nostra logica risiede in una funzione utili identifyLinksInTextIfAny che risiede in EditorUtils e viene chiamata all'interno del componente onChange in Editor .

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

Ecco identifyLinksInTextIfAny con la logica per il passaggio 1 implementata:

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

Ci sono due funzioni di supporto SlateJS che semplificano le cose qui.

  • Editor.before — Ci fornisce il punto prima di una determinata posizione. Prende l' unit come parametro in modo da poter chiedere il carattere/parola/blocco ecc. Prima che la location passata.
  • Editor.string — Ottiene la stringa all'interno di un intervallo.

Ad esempio, il diagramma seguente spiega quali sono i valori di queste variabili quando l'utente inserisce un carattere 'E' e il cursore si trova dopo di esso.

Diagramma che spiega dove puntano cursorPoint e startPointOfLastCharacter dopo il passaggio 1 con un esempio
cursorPoint e startPointOfLastCharacter dopo il passaggio 1 con un testo di esempio. (Grande anteprima)

Se il testo "ABCDE" fosse il primo nodo di testo del primo paragrafo del documento, i nostri valori in punti sarebbero:

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

Se l'ultimo carattere era uno spazio, sappiamo da dove è iniziato: startPointOfLastCharacter. Passiamo al passaggio 2 in cui ci spostiamo indietro carattere per carattere finché non troviamo un altro spazio o l'inizio del nodo di testo stesso.

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

Ecco un diagramma che mostra dove puntano questi diversi punti una volta che l'ultima parola inserita è ABCDE .

Diagramma che spiega dove si trovano i diversi punti dopo il passaggio 2 del rilevamento del collegamento con un esempio
Dove si trovano punti diversi dopo il passaggio 2 del rilevamento del collegamento con un esempio. (Grande anteprima)

Nota che start e end sono i punti prima e dopo lo spazio lì. Allo stesso modo, startPointOfLastCharacter e cursorPoint sono i punti prima e dopo lo spazio appena inserito dall'utente. Quindi [end,startPointOfLastCharacter] ci fornisce l'ultima parola inserita.

Registriamo il valore di lastWord sulla console e verifichiamo i valori durante la digitazione.

La console registra la verifica dell'ultima parola immessa dall'utente dopo la logica nel passaggio 2.

Ora che abbiamo dedotto quale è stata l'ultima parola digitata dall'utente, verifichiamo che fosse effettivamente un URL e convertiamo quell'intervallo in un oggetto collegamento. Questa conversione è simile al modo in cui il pulsante di collegamento della barra degli strumenti ha convertito il testo selezionato da un utente in un collegamento.

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

identifyLinksInTextIfAny viene chiamato all'interno di onChange di Slate, quindi non vorremmo aggiornare la struttura del documento all'interno di onChange . Quindi, inseriamo questo aggiornamento nella nostra coda di attività con una Promise.resolve().then(..) .

Vediamo che la logica si unisce in azione! Verifichiamo se inseriamo collegamenti alla fine, nel mezzo o all'inizio di un nodo di testo.

I collegamenti rilevati mentre l'utente li sta digitando.

Con ciò, abbiamo completato le funzionalità per i collegamenti sull'editor e passiamo a Immagini.

Gestione delle immagini

In questa sezione, ci concentriamo sull'aggiunta del supporto per il rendering dei nodi immagine, l'aggiunta di nuove immagini e l'aggiornamento delle didascalie delle immagini. Le immagini, nella nostra struttura del documento, sarebbero rappresentate come nodi Vuoti. I nodi Void in SlateJS (analoghi agli elementi Void nelle specifiche HTML) sono tali che il loro contenuto non è testo modificabile. Questo ci permette di rendere le immagini come vuoti. Grazie alla flessibilità di Slate con il rendering, possiamo ancora eseguire il rendering dei nostri elementi modificabili all'interno degli elementi Void, cosa che faremo per la modifica delle didascalie dell'immagine. SlateJS ha un esempio che mostra come incorporare un intero Rich Text Editor all'interno di un elemento Void.

Per eseguire il rendering delle immagini, configuriamo l'editor in modo che tratti le immagini come elementi Void e forniamo un'implementazione di rendering di come le immagini dovrebbero essere renderizzate. Aggiungiamo un'immagine al nostro ExampleDocument e verifichiamo che venga visualizzata correttamente con la didascalia.

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

Due cose da ricordare quando si tenta di eseguire il rendering di nodi vuoti con SlateJS:

  • L'elemento DOM radice dovrebbe avere contentEditable={false} impostato su di esso in modo che SlateJS tratti il ​​suo contenuto in questo modo. Senza questo, mentre interagisci con l'elemento void, SlateJS potrebbe provare a calcolare selezioni ecc. e di conseguenza interrompersi.
  • Anche se i nodi Void non hanno alcun nodo figlio (come il nostro nodo immagine come esempio), abbiamo comunque bisogno di renderizzare children e fornire un nodo di testo vuoto come figlio (vedi ExampleDocument seguito) che viene trattato come un punto di selezione di Void elemento di SlateJS

Ora aggiorniamo ExampleDocument per aggiungere un'immagine e verificare che venga visualizzata con la didascalia nell'editor.

 # 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: "" }], }, ]; 
Immagine renderizzata nell'editor
Immagine renderizzata nell'editor. (Grande anteprima)

Ora concentriamoci sulla modifica dei sottotitoli. Il modo in cui vogliamo che questa sia un'esperienza senza interruzioni per l'utente è che quando fanno clic sulla didascalia, mostriamo un input di testo in cui possono modificare la didascalia. Se fanno clic al di fuori dell'input o premono il tasto INVIO, lo trattiamo come una conferma per applicare la didascalia. Quindi aggiorniamo la didascalia sul nodo immagine e riportiamo la didascalia in modalità lettura. Vediamolo in azione così abbiamo un'idea di cosa stiamo costruendo.

Modifica didascalia immagine in azione.

Aggiorniamo il nostro componente Immagine per avere uno stato per le modalità di lettura e modifica della didascalia. Aggiorniamo lo stato della didascalia locale quando l'utente lo aggiorna e quando fa clic su ( onBlur ) o preme RETURN ( onKeyDown ), applichiamo la didascalia al nodo e passiamo nuovamente alla modalità di lettura.

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

Con ciò, la funzionalità di modifica dei sottotitoli è completa. Passiamo ora all'aggiunta di un modo per consentire agli utenti di caricare immagini nell'editor. Aggiungiamo un pulsante della barra degli strumenti che consente agli utenti di selezionare e caricare un'immagine.

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

Poiché lavoriamo con i caricamenti di immagini, il codice potrebbe crescere un po', quindi spostiamo la gestione del caricamento di immagini su un hook useImageUploadHandler che emette un callback allegato all'elemento di input del file. Discuteremo a breve del motivo per cui è necessario lo stato previousSelection .

Prima di implementare useImageUploadHandler , configureremo il server per poter caricare un'immagine su. Impostiamo un server Express e installiamo altri due pacchetti: cors e multer che gestiscono i caricamenti di file per noi.

 yarn add express cors multer

Quindi aggiungiamo uno script src/server.js che configura il server Express con cors e multer ed espone un endpoint /upload su cui caricheremo l'immagine.

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

Ora che abbiamo la configurazione del server, possiamo concentrarci sulla gestione del caricamento delle immagini. Quando l'utente carica un'immagine, potrebbero essere necessari alcuni secondi prima che l'immagine venga caricata e abbiamo un URL per essa. Tuttavia, facciamo ciò per fornire all'utente un feedback immediato che il caricamento dell'immagine è in corso in modo che sappia che l'immagine è stata inserita nell'editor. Ecco i passaggi che implementiamo per far funzionare questo comportamento:

  1. Una volta che l'utente seleziona un'immagine, inseriamo un nodo immagine nella posizione del cursore dell'utente con un flag isUploading impostato su di esso in modo da poter mostrare all'utente uno stato di caricamento.
  2. Inviamo la richiesta al server di caricare l'immagine.
  3. Una volta che la richiesta è completa e abbiamo un URL dell'immagine, lo impostiamo sull'immagine e rimuoviamo lo stato di caricamento.

Iniziamo con il primo passaggio in cui inseriamo il nodo immagine. Ora, la parte difficile qui è che incontriamo lo stesso problema con la selezione del pulsante di collegamento nella barra degli strumenti. Non appena l'utente fa clic sul pulsante Immagine nella barra degli strumenti, l'editor perde lo stato attivo e la selezione diventa null . Se proviamo a inserire un'immagine, non sappiamo dove fosse il cursore dell'utente. Il monitoraggio di previousSelection ci fornisce quella posizione e la usiamo per inserire il nodo.

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

Quando inseriamo il nuovo nodo immagine, gli assegniamo anche un id identificatore usando il pacchetto uuid. Discuteremo nell'implementazione del passaggio (3) perché ne abbiamo bisogno. Ora aggiorniamo il componente immagine per utilizzare il flag isUploading per mostrare uno stato di caricamento.

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

Ciò completa l'implementazione del passaggio 1. Verifichiamo di essere in grado di selezionare un'immagine da caricare, vedere il nodo dell'immagine inserito con un indicatore di caricamento nel punto in cui è stato inserito nel documento.

Caricamento immagine creando un nodo immagine con stato di caricamento.

Passando al passaggio (2), utilizzeremo la libreria axois per inviare una richiesta al server.

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

Verifichiamo che il caricamento dell'immagine funzioni e che l'immagine venga visualizzata nella cartella public/photos dell'app. Ora che il caricamento dell'immagine è completo, passiamo al passaggio (3) dove vogliamo impostare l'URL sull'immagine nella funzione resolve() della promessa di axios. Potremmo aggiornare l'immagine con Transforms.setNodes ma abbiamo un problema: non abbiamo il percorso per il nodo dell'immagine appena inserito. Vediamo quali sono le nostre opzioni per ottenere quell'immagine —

  • Non possiamo usare editor.selection poiché la selezione deve essere sul nodo dell'immagine appena inserito? Non possiamo garantirlo poiché durante il caricamento dell'immagine, l'utente potrebbe aver fatto clic da qualche altra parte e la selezione potrebbe essere cambiata.
  • Che ne dici di usare previousSelection che abbiamo usato per inserire il nodo dell'immagine in primo luogo? Per lo stesso motivo non possiamo usare editor.selection , non possiamo usare previousSelection poiché potrebbe essere cambiato anche lui.
  • SlateJS ha un modulo Cronologia che tiene traccia di tutte le modifiche avvenute al documento. Potremmo usare questo modulo per cercare nella cronologia e trovare l'ultimo nodo immagine inserito. Anche questo non è completamente affidabile se il caricamento dell'immagine ha richiesto più tempo e l'utente ha inserito più immagini in diverse parti del documento prima del completamento del primo caricamento.
  • Attualmente, l'API di Transform.insertNodes non restituisce alcuna informazione sui nodi inseriti. Se potesse restituire i percorsi ai nodi inseriti, potremmo usarlo per trovare il nodo immagine preciso che dovremmo aggiornare.

Poiché nessuno degli approcci precedenti funziona, applichiamo un id al nodo dell'immagine inserito (nel passaggio (1)) e utilizziamo di nuovo lo stesso id per individuarlo quando il caricamento dell'immagine è completo. Con ciò, il nostro codice per il passaggio (3) è simile al seguente:

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

Una volta completata l'implementazione di tutti e tre i passaggi, siamo pronti per testare il caricamento dell'immagine end-to-end.

Caricamento immagine funzionante end-to-end

Con questo, abbiamo concluso Immagini per il nostro editor. Attualmente, mostriamo uno stato di caricamento della stessa dimensione indipendentemente dall'immagine. Questa potrebbe essere un'esperienza stridente per l'utente se lo stato di caricamento viene sostituito da un'immagine drasticamente più piccola o più grande al termine del caricamento. Un buon seguito all'esperienza di caricamento è ottenere le dimensioni dell'immagine prima del caricamento e mostrare un segnaposto di quelle dimensioni in modo che la transizione sia perfetta. Il gancio che aggiungiamo sopra potrebbe essere esteso per supportare altri tipi di media come video o documenti e renderizzare anche quei tipi di nodi.

Conclusione

In questo articolo, abbiamo creato un editor WYSIWYG che ha un insieme di funzionalità di base e alcune micro esperienze utente come il rilevamento dei collegamenti, la modifica dei collegamenti sul posto e la modifica delle didascalie delle immagini che ci hanno aiutato ad approfondire SlateJS e i concetti di modifica del testo RTF in generale. Se ti interessa questo spazio problematico che circonda la modifica del testo RTF o l'elaborazione di testi, alcuni dei problemi interessanti da affrontare potrebbero essere:

  • Collaborazione
  • Un'esperienza di modifica del testo più ricca che supporta allineamenti del testo, immagini in linea, copia-incolla, modifica dei colori dei caratteri e del testo, ecc.
  • Importazione da formati popolari come documenti Word e Markdown.

Se vuoi saperne di più su SlateJS, ecco alcuni link che potrebbero essere utili.

  • Esempi di SlateJS
    Molti esempi che vanno oltre le basi e costruiscono funzionalità che di solito si trovano negli editor come Cerca ed evidenzia, Anteprima Markdown e Menzioni.
  • Documenti API
    Riferimento a molte funzioni di supporto esposte da SlateJS che si potrebbe voler tenere a portata di mano quando si tenta di eseguire query/trasformazioni complesse su oggetti SlateJS.

Infine, Slack Channel di SlateJS è una comunità molto attiva di sviluppatori Web che creano applicazioni di modifica del testo ricco utilizzando SlateJS e un ottimo posto per saperne di più sulla libreria e ottenere aiuto se necessario.