Crearea unui editor de text îmbogățit (WYSIWYG)

Publicat: 2022-03-10
Rezumat rapid ↬ În acest articol, vom învăța cum să construim un editor WYSIWYG/Rich-Text care acceptă text îmbogățit, imagini, link-uri și unele funcții nuanțate din aplicațiile de procesare de text. Vom folosi SlateJS pentru a construi shell-ul editorului și apoi vom adăuga o bară de instrumente și configurații personalizate. Codul aplicației este disponibil pe GitHub pentru referință.

În ultimii ani, domeniul creării și reprezentării conținutului pe platformele digitale a cunoscut o perturbare masivă. Succesul pe scară largă al unor produse precum Quip, Google Docs și Dropbox Paper a arătat cum companiile se întrec pentru a construi cea mai bună experiență pentru creatorii de conținut în domeniul întreprinderilor și încearcă să găsească modalități inovatoare de a depăși modelele tradiționale ale modului în care conținutul este partajat și consumat. Profitând de extinderea masivă a platformelor de social media, există un nou val de creatori de conținut independenți care folosesc platforme precum Medium pentru a crea conținut și a-l partaja cu publicul lor.

Deoarece atât de mulți oameni din profesii și medii diferite încearcă să creeze conținut pe aceste produse, este important ca aceste produse să ofere o experiență performantă și fără întreruperi de creare a conținutului și să aibă echipe de designeri și ingineri care dezvoltă un anumit nivel de expertiză în domeniu de-a lungul timpului în acest spațiu. . Cu acest articol, încercăm nu numai să punem bazele construirii unui editor, ci și să oferim cititorilor o privire asupra modului în care micile funcționalități, atunci când sunt reunite, pot crea o experiență excelentă pentru utilizator pentru un creator de conținut.

Înțelegerea structurii documentului

Înainte de a ne aprofunda în construirea editorului, să vedem cum este structurat un document pentru un editor de text îmbogățit și care sunt diferitele tipuri de structuri de date implicate.

Noduri de document

Nodurile de document sunt folosite pentru a reprezenta conținutul documentului. Tipurile comune de noduri pe care le poate conține un document cu text îmbogățit sunt paragrafe, titluri, imagini, videoclipuri, blocuri de cod și ghilimele. Unele dintre acestea pot conține alte noduri ca copii în interiorul lor (de exemplu, nodurile Paragraf conțin noduri de text în interiorul lor). Nodurile dețin, de asemenea, orice proprietăți specifice obiectului pe care îl reprezintă, necesare pentru a reda acele noduri în editor. (de exemplu, nodurile de imagine conțin o proprietate src de imagine, blocurile de cod pot conține o proprietate de language și așa mai departe).

Există în mare parte două tipuri de noduri care reprezintă modul în care ar trebui să fie redate -

  • Noduri de bloc (analog conceptului HTML al elementelor la nivel de bloc) care sunt randate fiecare pe o linie nouă și ocupă lățimea disponibilă. Nodurile bloc ar putea conține alte noduri bloc sau noduri inline în interiorul lor. O observație aici este că nodurile de nivel superior ale unui document ar fi întotdeauna noduri de bloc.
  • Noduri Inline (analog conceptului HTML al elementelor Inline) care încep să se redeze pe aceeași linie ca și nodul anterior. Există unele diferențe în modul în care elementele inline sunt reprezentate în diferite biblioteci de editare. SlateJS permite ca elementele inline să fie ele însele noduri. DraftJS, o altă bibliotecă populară de editare a textului îmbogățit, vă permite să utilizați conceptul de Entități pentru a reda elementele inline. Legăturile și Imaginile Inline sunt exemple de noduri Inline.
  • Void Nodes — SlateJS permite, de asemenea, această a treia categorie de noduri pe care le vom folosi mai târziu în acest articol pentru a reda media.

Dacă doriți să aflați mai multe despre aceste categorii, documentația SlateJS despre Noduri este un loc bun pentru a începe.

Mai multe după săritură! Continuați să citiți mai jos ↓

Atribute

Similar cu conceptul HTML de atribute, atributele dintr-un document Rich Text sunt folosite pentru a reprezenta proprietățile non-conținut ale unui nod sau ale copiilor acestuia. De exemplu, un nod de text poate avea atribute de tip caracter care ne spun dacă textul este aldin/italic/subliniat și așa mai departe. Deși acest articol reprezintă titlurile ca noduri în sine, un alt mod de a le reprezenta ar putea fi ca nodurile să aibă stiluri de paragraf ( paragraph și h1-h6 ) ca atribute pe ele.

Imaginea de mai jos oferă un exemplu despre modul în care structura unui document (în JSON) este descrisă la un nivel mai granular folosind noduri și atribute care evidențiază unele dintre elementele din structura din stânga.

Imagine care arată un exemplu de document în interiorul editorului cu reprezentarea structurii sale în stânga
Document de exemplu și reprezentarea sa structurală. (Previzualizare mare)

Unele dintre lucrurile care merită menționate aici cu structura sunt:

  • Nodurile de text sunt reprezentate ca {text: 'text content'}
  • Proprietățile nodurilor sunt stocate direct pe nod (ex. url pentru link-uri și caption pentru imagini)
  • Reprezentarea specifică SlateJS a atributelor text rupe nodurile text pentru a fi propriile noduri dacă stilul caracterului se schimbă. Prin urmare, textul „ Duis aute irure dolor ” este un nod de text propriu, cu bold: true . Același lucru este cazul textului în stil italic, subliniat și cod din acest document.

Locații și selecție

Când construiți un editor de text îmbogățit, este esențial să înțelegeți cum partea cea mai granulară a unui document (să zicem un caracter) poate fi reprezentată cu un fel de coordonate. Acest lucru ne ajută să navigăm în structura documentului în timpul execuției pentru a înțelege unde ne aflăm în ierarhia documentelor. Cel mai important, obiectele de locație ne oferă o modalitate de a reprezenta selecția utilizatorului, care este folosită destul de mult pentru a personaliza experiența utilizatorului a editorului în timp real. Vom folosi selecția pentru a construi bara de instrumente mai târziu în acest articol. Exemple dintre acestea ar putea fi:

  • Cursorul utilizatorului se află în prezent într-un link, poate ar trebui să le arătăm un meniu pentru a edita/elimina linkul?
  • A selectat utilizatorul o imagine? Poate le oferim un meniu pentru a redimensiona imaginea.
  • Dacă utilizatorul selectează un anumit text și apasă butonul DELETE, determinăm ce text selectat de utilizator a fost și îl eliminăm din document.

Documentul SlateJS despre Locație explică pe larg aceste structuri de date, dar le parcurgem rapid aici, deoarece folosim acești termeni în diferite cazuri din articol și arătăm un exemplu în diagrama care urmează.

  • cale
    Reprezentată printr-o matrice de numere, o cale este modalitatea de a ajunge la un nod din document. De exemplu, o cale [2,3] reprezintă al 3-lea nod copil al celui de-al 2-lea nod din document.
  • Punct
    Locație mai granulară a conținutului reprezentată de cale + offset. De exemplu, un punct de {path: [2,3], offset: 14} reprezintă al 14-lea caracter al celui de-al 3-lea nod copil din interiorul celui de-al 2-lea nod al documentului.
  • Gamă
    O pereche de puncte (numite anchor și focus ) care reprezintă o gamă de text în interiorul documentului. Acest concept provine din API-ul Web de selecție, unde anchor este locul unde a început selecția utilizatorului și focus este locul unde s-a terminat. Un interval/selectie restrâns indică locul în care punctele de ancorare și de focalizare sunt aceleași (gândiți-vă la un cursor care clipește într-o introducere de text, de exemplu).

Ca exemplu, să presupunem că selecția utilizatorului în exemplul nostru de document de mai sus este ipsum :

Imagine cu textul `ipsum` selectat în editor
Utilizatorul selectează cuvântul ipsum . (Previzualizare mare)

Selecția utilizatorului poate fi reprezentată ca:

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

Configurarea Editorului

În această secțiune, vom configura aplicația și vom obține un editor de text îmbogățit de bază cu SlateJS. Aplicația boilerplate ar fi create-react-app cu dependențe SlateJS adăugate la ea. Construim interfața de utilizare a aplicației folosind componente din react-bootstrap . Să începem!

Creați un folder numit wysiwyg-editor și rulați comanda de mai jos din interiorul directorului pentru a configura aplicația react. Apoi rulăm o comandă de yarn start care ar trebui să pornească serverul web local (portul este implicit 3000) și să vă arate un ecran de bun venit React.

 npx create-react-app . yarn start

Apoi trecem la adăugarea dependențelor SlateJS în aplicație.

 yarn add slate slate-react

slate este pachetul de bază al SlateJS și slate-react include setul de componente React pe care îl vom folosi pentru a reda editorii Slate. SlateJS expune mai multe pachete organizate în funcție de funcționalitatea pe care s-ar putea lua în considerare adăugarea la editorul lor.

Mai întâi creăm un folder utils care conține orice module utilitare pe care le creăm în această aplicație. Începem cu crearea unui ExampleDocument.js care returnează o structură de bază a documentului care conține un paragraf cu ceva text. Acest modul arată ca mai jos:

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

Acum adăugăm un folder numit components care va păstra toate componentele noastre React și facem următoarele:

  • Adăugați prima noastră componentă React Editor.js la acesta. Deocamdată returnează doar un div .
  • Actualizați componenta App.js pentru a menține documentul în starea sa care este inițializată la ExampleDocument nostru de document de mai sus.
  • Redați Editorul în interiorul aplicației și transmiteți starea documentului și un handler onChange către Editor, astfel încât starea documentului nostru să fie actualizată pe măsură ce utilizatorul îl actualizează.
  • Folosim componentele Nav ale lui React bootstrap pentru a adăuga și o bară de navigare la aplicație.

Componenta App.js arată acum mai jos:

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

În interiorul componentei Editor, instanțiăm apoi editorul SlateJS și îl ținem într-un useMemo , astfel încât obiectul să nu se schimbe între re-rendări.

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

createEditor ne oferă instanța editor SlateJS pe care o folosim pe scară largă prin intermediul aplicației pentru a accesa selecții, a rula transformări de date și așa mai departe. withReact este un plugin SlateJS care adaugă comportamente React și DOM la obiectul editor. Pluginurile SlateJS sunt funcții Javascript care primesc obiectul editor și îi atașează o anumită configurație. Acest lucru le permite dezvoltatorilor web să adauge configurații la instanța lor de editor SlateJS într-un mod compus.

Acum importăm și redăm componentele <Slate /> și <Editable /> din SlateJS cu ajutorul documentului pe care îl obținem de la App.js. Slate expune o grămadă de contexte React pe care le folosim pentru a le accesa în codul aplicației. Editable este componenta care redă ierarhia documentului pentru editare. În general, modulul Editor.js în această etapă arată ca mai jos:

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

În acest moment, avem componentele React necesare adăugate și editorul populat cu un exemplu de document. Editorul nostru ar trebui să fie configurat acum, permițându-ne să introducem și să modificăm conținutul în timp real - ca în screencast-ul de mai jos.

Configurarea de bază a editorului în acțiune

Acum, să trecem la următoarea secțiune în care configuram editorul pentru a reda stilurile de caractere și nodurile de paragraf.

RENDAREA TEXTULUI PERSONALIZATĂ ȘI O BARĂ DE INSTRUMENTE

Noduri de stil de paragraf

În prezent, editorul nostru folosește redarea implicită SlateJS pentru orice tip de noduri noi pe care le putem adăuga în document. În această secțiune, dorim să putem reda nodurile de antet. Pentru a putea face acest lucru, oferim componentelor lui Slate o funcție renderElement . Această funcție este apelată de Slate în timpul execuției atunci când încearcă să traverseze arborele documentului și să redea fiecare nod. Funcția renderElement primește trei parametri —

  • attributes
    Specific SlateJS care trebuie să fie aplicat elementului DOM de nivel superior returnat din această funcție.
  • element
    Obiectul nod în sine așa cum există în structura documentului
  • children
    Fiii acestui nod așa cum sunt definiți în structura documentului.

Adăugăm implementarea noastră renderElement la un cârlig numit useEditorConfig unde vom adăuga mai multe configurații de editor pe măsură ce mergem. Apoi folosim cârligul de pe instanța editorului din 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} />; } }

Deoarece această funcție ne oferă acces la element (care este nodul în sine), putem personaliza renderElement pentru a implementa o randare mai personalizată care face mai mult decât verificarea element.type . De exemplu, ați putea avea un nod de imagine care are o proprietate isInline pe care am putea-o folosi pentru a returna o structură DOM diferită care ne ajută să redăm imagini în linie față de imaginile bloc.

Acum actualizăm componenta Editor pentru a folosi acest cârlig după cum urmează:

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

Cu randarea personalizată, actualizăm ExampleDocument pentru a include noile noastre tipuri de noduri și pentru a verifica dacă acestea se redau corect în editor.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
Imagine care arată diferite titluri și noduri de paragraf redate în editor
Nodurile de titluri și paragraf în Editor. (Previzualizare mare)

Stiluri de caractere

Similar cu renderElement , SlateJS oferă o funcție de sprijin numită renderLeaf care poate fi folosită pentru a personaliza redarea nodurilor de text ( Leaf se referă la nodurile de text care sunt frunzele/nodurile de cel mai jos nivel ale arborelui documentului). Urmând exemplul renderElement , scriem o implementare pentru 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>; }

O observație importantă a implementării de mai sus este că ne permite să respectăm semantica HTML pentru stilurile de caractere. Deoarece renderLeaf ne oferă acces la leaf nodului text în sine, putem personaliza funcția pentru a implementa o randare mai personalizată. De exemplu, este posibil să aveți o modalitate de a permite utilizatorilor să aleagă o culoare de highlightColor pentru text și să verificați aici proprietatea frunzei pentru a atașa stilurile respective.

Acum actualizăm componenta Editor pentru a folosi cele de mai sus, ExampleDocument pentru a avea câteva noduri de text în paragraful cu combinații ale acestor stiluri și verificăm că acestea sunt redate așa cum era de așteptat în Editor cu etichetele semantice pe care le-am folosit.

 # 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 }, ], }, 
Stilurile de caractere în UI și modul în care sunt redate în arborele DOM
Stilurile de caractere în UI și modul în care sunt redate în arborele DOM. (Previzualizare mare)

Adăugarea unei bare de instrumente

Să începem prin a adăuga o nouă componentă Toolbar.js la care adăugăm câteva butoane pentru stilurile de caractere și un meniu derulant pentru stilurile de paragraf și le conectăm mai târziu în secțiune.

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

Retragem butoanele în componenta ToolbarButton , care este un înveliș în jurul componentei React Bootstrap Button. Apoi redăm bara de instrumente deasupra componentei Editable în interiorul Editor și verificăm dacă bara de instrumente apare în aplicație.

Imagine care arată bara de instrumente cu butoane redate deasupra editorului
Bara de instrumente cu butoane (Previzualizare mare)

Iată cele trei funcționalități cheie pe care trebuie să le susțină bara de instrumente:

  1. Când cursorul utilizatorului se află într-un anumit loc din document și dă clic pe unul dintre butoanele de stil de caractere, trebuie să comutăm stilul pentru textul pe care îl poate introduce în continuare.
  2. Când utilizatorul selectează o gamă de text și face clic pe unul dintre butoanele stilului caracterului, trebuie să comutăm stilul pentru acea secțiune specifică.
  3. Când utilizatorul selectează un interval de text, dorim să actualizăm meniul derulant pentru stilul de paragraf pentru a reflecta tipul de paragraf al selecției. Dacă selectează o valoare diferită de selecție, dorim să actualizăm stilul de paragraf al întregii selecții pentru a fi ceea ce au selectat.

Să ne uităm la modul în care funcționează aceste funcționalități în Editor înainte de a începe să le implementăm.

Comportamentul de comutare a stilurilor de caractere

Ascultarea Selecției

Cel mai important lucru de care are nevoie Bara de instrumente pentru a putea îndeplini funcțiile de mai sus este starea de selecție a documentului. În momentul scrierii acestui articol, SlateJS nu expune o metodă onSelectionChange care ne-ar putea oferi cea mai recentă stare de selecție a documentului. Cu toate acestea, pe măsură ce selecția se modifică în editor, onChange apelează metoda onChange, chiar dacă conținutul documentului nu s-a schimbat. Folosim acest lucru ca o modalitate de a fi notificat cu privire la modificarea selecției și de a o stoca în starea componentei Editor . Extragem acest lucru la un hook useSelection unde am putea face o actualizare mai optimă a stării de selecție. Acest lucru este important deoarece selecția este o proprietate care se modifică destul de des pentru o instanță 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]; }

Folosim acest cârlig în interiorul componentei Editor ca mai jos și trecem selecția la componenta 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} /> ...

Considerarea performanței

Într-o aplicație în care avem o bază de cod Editor mult mai mare, cu mult mai multe funcționalități, este important să stocăm și să ascultăm modificările de selecție într-un mod performant (cum ar fi folosirea unei biblioteci de management de stat), deoarece componentele care ascultă modificările de selecție sunt susceptibile să fie redate. de multe ori. O modalitate de a face acest lucru este să aveți selectoare optimizate deasupra stării de selecție care să dețină informații specifice de selecție. De exemplu, un editor ar putea dori să redea un meniu de redimensionare a unei imagini atunci când este selectată o imagine. Într-un astfel de caz, ar putea fi util ca un selector isImageSelected să fie calculat din starea de selecție a editorului, iar meniul Image ar fi redat din nou numai atunci când valoarea acestui selector se schimbă. Redux's Reselect este o astfel de bibliotecă care permite construirea selectoarelor.

Nu folosim selection în interiorul barei de instrumente decât mai târziu, dar transmiterea acesteia ca o prop face ca bara de instrumente să fie redată de fiecare dată când selecția se schimbă în Editor. Facem acest lucru pentru că nu ne putem baza doar pe modificarea conținutului documentului pentru a declanșa o re-rendare în ierarhie ( App -> Editor -> Toolbar de instrumente), deoarece utilizatorii pot continua să facă clic pe document, modificând astfel selecția, dar nu schimbând niciodată conținutul documentului. în sine.

Comutarea stilurilor de caractere

Acum trecem la a obține care sunt stilurile de caractere active din SlateJS și a le folosi în interiorul Editorului. Să adăugăm un nou modul JS EditorUtils care va găzdui toate funcțiile utili pe care le construim în continuare pentru a obține/a face lucruri cu SlateJS. Prima noastră funcție din modul este getActiveStyles care oferă un Set de stiluri active în editor. Adăugăm, de asemenea, o funcție pentru a comuta un stil în funcția de 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); } }

Ambele funcții iau ca parametru obiectul editor , care este instanța Slate, la fel ca multe funcții utile pe care le adăugam mai târziu în articol. În terminologia Slate, stilurile de formatare se numesc Marks și folosim metode de ajutor pe interfața Editor pentru a obține, adăuga și eliminați aceste semne. Importăm aceste funcții utile în Bara de instrumente și le conectăm la butoanele pe care le-am adăugat mai devreme.

 # 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 este un cârlig Slate care ne oferă acces la instanța Slate din contextul în care a fost atașată de componenta &lt;Slate> mai sus în ierarhia de randare.

S-ar putea să se întrebe de ce folosim onMouseDown aici în loc de onClick ? Există o problemă Github deschisă despre modul în care Slate transformă selection în null atunci când editorul își pierde focalizarea în vreun fel. Deci, dacă atașăm handlerele onClick la butoanele din bara de instrumente, selection devine null și utilizatorii își pierd poziția cursorului încercând să comute un stil care nu este o experiență grozavă. În schimb, comutăm stilul prin atașarea unui eveniment onMouseDown care împiedică resetarea selecției. O altă modalitate de a face acest lucru este să urmărim noi înșine selecția, astfel încât să știm care a fost ultima selecție și să o folosim pentru a comuta între stiluri. Introducem conceptul de selecție previousSelection mai târziu în articol, dar pentru a rezolva o altă problemă.

SlateJS ne permite să configuram handlere de evenimente în Editor. Îl folosim pentru a conecta comenzile rapide de la tastatură pentru a comuta stilurile de caractere. Pentru a face asta, adăugăm un obiect KeyBindings în useEditorConfig unde expunem un handler de evenimente onKeyDown atașat la componenta Editable . Folosim utilitarul is-hotkey pentru a determina combinația de taste și pentru a comuta stilul corespunzător.

 # 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} /> 
Stilurile de caractere au fost comutate folosind comenzile rapide de la tastatură.

Funcționarea meniului derulant al stilului de paragraf

Să trecem la ca meniul derulant Stiluri de paragraf să funcționeze. Similar cu modul în care funcționează meniurile derulante în stil de paragraf în aplicațiile populare de procesare a textului, cum ar fi MS Word sau Google Docs, dorim ca stilurile blocurilor de nivel superior din selecția utilizatorului să fie reflectate în meniul derulant. Dacă există un singur stil consecvent în întreaga selecție, actualizăm valoarea drop-down pentru a fi aceea. Dacă există mai multe dintre acestea, setăm valoarea drop-down să fie „Multiple”. Acest comportament trebuie să funcționeze atât pentru selecțiile restrânse, cât și pentru cele extinse.

Pentru a implementa acest comportament, trebuie să putem găsi blocurile de nivel superior care acoperă selecția utilizatorului. Pentru a face acest lucru, folosim Slate's Editor.nodes — O funcție de ajutor folosită în mod obișnuit pentru a căuta noduri într-un arbore filtrat după diferite opțiuni.

 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>

Funcția de ajutor ia o instanță Editor și un obiect options care este o modalitate de a filtra nodurile din arbore pe măsură ce îl traversează. Funcția returnează un generator de NodeEntry . Un NodeEntry în terminologia Slate este un tuplu al unui nod și calea către acesta — [node, pathToNode] . Opțiunile găsite aici sunt disponibile pentru majoritatea funcțiilor de ajutor Slate. Să vedem ce înseamnă fiecare dintre acestea:

  • at
    Acesta poate fi o Cale/Punct/Range pe care funcția de ajutor l-ar folosi pentru a urmări traversarea arborelui. Acesta este implicit editor.selection dacă nu este furnizat. De asemenea, folosim valoarea implicită pentru cazul nostru de utilizare de mai jos, deoarece suntem interesați de nodurile din selecția utilizatorului.
  • match
    Aceasta este o funcție de potrivire pe care o poate furniza, care este apelată pe fiecare nod și inclusă dacă este o potrivire. Folosim acest parametru în implementarea noastră de mai jos pentru a filtra pentru a bloca numai elemente.
  • mode
    Să știe funcțiile de ajutor dacă suntem interesați de toate nodurile de at mai înalt nivel sau de cel mai jos nivel la funcția de match a locației date. Acest parametru (setat la highest ) ne ajută să scăpăm de a încerca să traversăm arborele în sus pentru a găsi nodurile de nivel superior.
  • universal
    Semnal pentru a alege între potrivirile totale sau parțiale ale nodurilor. (Problema GitHub cu propunerea pentru acest steag are câteva exemple care o explică)
  • reverse
    Dacă căutarea nodului ar trebui să fie în direcția inversă punctelor de început și de sfârșit ale locației trecute.
  • voids
    Dacă căutarea ar trebui să filtreze numai pentru elementele nule.

SlateJS expune o mulțime de funcții de ajutor care vă permit să căutați noduri în moduri diferite, să traversați arborele, să actualizați nodurile sau selecțiile în moduri complexe. Merită să explorați unele dintre aceste interfețe (enumerate la sfârșitul acestui articol) atunci când construiți funcționalități complexe de editare pe Slate.

Cu acest fundal asupra funcției de ajutor, mai jos este o implementare a lui 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; }

Considerarea performanței

Implementarea actuală a Editor.nodes găsește toate nodurile din arborele pe toate nivelurile care se află în intervalul parametrului at și apoi rulează filtre de potrivire pe acesta (verificați nodeEntries și filtrarea mai târziu - sursă). Acest lucru este în regulă pentru documente mai mici. Cu toate acestea, pentru cazul nostru de utilizare, dacă utilizatorul a selectat, să spunem 3 titluri și 2 paragrafe (fiecare paragraf conținând să zicem 10 noduri de text), acesta va parcurge cel puțin 25 de noduri (3 + 2 + 2*10) și va încerca să ruleze filtre. pe ei. Deoarece știm deja că suntem interesați doar de nodurile de nivel superior, am putea găsi indici de început și de sfârșit ai blocurilor de nivel superior din selecție și să ne repetăm. O astfel de logică ar trece prin doar 3 intrări de nod (2 titluri și 1 paragraf). Codul pentru asta ar arăta mai jos:

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

Pe măsură ce adăugăm mai multe funcționalități la un editor WYSIWYG și trebuie să traversăm des arborele documentului, este important să ne gândim la cele mai performante modalități de a face acest lucru pentru cazul de utilizare în cauză, deoarece API-ul disponibil sau metodele de ajutor ar putea să nu fie întotdeauna cele mai bune. mod eficient de a face acest lucru.

Odată ce am implementat getTextBlockStyle , comutarea stilului bloc este relativ simplă. Dacă stilul curent nu este cel selectat de utilizator în meniul derulant, comutăm stilul la acesta. Dacă este deja ceea ce a selectat utilizatorul, îl comutăm să fie un paragraf. Deoarece reprezentăm stilurile de paragraf ca noduri în structura documentului nostru, comutarea unui stil de paragraf înseamnă în esență schimbarea proprietății type de pe nod. Folosim Transforms.setNodes oferit de Slate pentru a actualiza proprietățile nodurilor.

Implementarea noastră toggleBlockType este după cum urmează:

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

În cele din urmă, actualizăm meniul derulant Paragraph-Style pentru a folosi aceste funcții utilitare.

 #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> .... ); 
Selectarea mai multor tipuri de bloc și schimbarea tipului cu meniul drop-down.

LINK-URI

În această secțiune, vom adăuga suport pentru a afișa, adăuga, elimina și modifica link-uri. Vom adăuga, de asemenea, o funcționalitate Link-Detector - destul de similară cu Google Docs sau MS Word care scanează textul tastat de utilizator și verifică dacă există linkuri acolo. Dacă există, acestea sunt convertite în obiecte link, astfel încât utilizatorul să nu fie nevoit să folosească butoanele din bara de instrumente pentru a face acest lucru el însuși.

Redarea link-urilor

În editorul nostru, vom implementa link-uri ca noduri inline cu SlateJS. Actualizăm configurația editorului nostru pentru a semnala link-urile ca noduri inline pentru SlateJS și, de asemenea, oferim o componentă de randare, astfel încât Slate să știe cum să redea nodurile de link.

 # 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. (Previzualizare mare)

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>

Deoarece LinkEditor în afara editorului, avem nevoie de o modalitate de a spune LinkEditor unde se află linkul în arborele DOM, astfel încât să se poată reda lângă editor. Modul în care facem acest lucru este să folosim API-ul Slate React pentru a găsi nodul DOM corespunzător nodului de legătură în selecție. Și apoi folosim getBoundingClientRect() pentru a găsi limitele elementului DOM al link-ului și limitele componentei editor și pentru a calcula partea de top și left pentru editorul de link. Actualizările de cod pentru Editor și LinkEditor sunt după cum urmează -

 # 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 menține intern hărțile nodurilor către elementele DOM respective. Accesăm acea hartă și găsim elementul DOM al link-ului folosind ReactEditor.toDOMNode .

Selectarea în interiorul unui link arată popover-ul editorului de linkuri.

După cum se vede în videoclipul de mai sus, atunci când un link este inserat și nu are o adresă URL, deoarece selecția se află în interiorul linkului, deschide editorul de linkuri, oferind astfel utilizatorului o modalitate de a introduce o adresă URL pentru linkul nou introdus și prin urmare, închide bucla experienței utilizatorului acolo.

Acum adăugăm un element de intrare și un buton la LinkEditor care îi permit utilizatorului să introducă o adresă URL și să o aplice la nodul de legătură. Folosim pachetul isUrl pentru validarea URL-ului.

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

Cu elementele formularului conectate, să vedem dacă editorul de linkuri funcționează conform așteptărilor.

Editorul pierde selecția făcând clic în interiorul editorului de linkuri

După cum vedem aici în videoclip, atunci când utilizatorul încearcă să facă clic în intrare, editorul de linkuri dispare. Acest lucru se datorează faptului că, pe măsură ce redăm editorul de linkuri în afara componentei Editable , atunci când utilizatorul face clic pe elementul de intrare, SlateJS crede că editorul și-a pierdut focalizarea și resetează selection pentru a fi null , ceea ce elimină LinkEditor , deoarece isLinkActiveAtSelection nu mai este true . Există o problemă GitHub deschisă care vorbește despre acest comportament Slate. O modalitate de a rezolva acest lucru este să urmărim selecția anterioară a unui utilizator pe măsură ce se schimbă și, atunci când editorul își pierde focalizarea, am putea să ne uităm la selecția anterioară și să arătăm în continuare un meniu al editorului de linkuri dacă selecția anterioară a avut un link în ea. Să actualizăm cârligul useSelection pentru a ne aminti selecția anterioară și să o returnăm la componenta 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]; }

Actualizăm apoi logica din componenta Editor pentru a afișa meniul de linkuri, chiar dacă selecția anterioară avea un link în ea.

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

Apoi actualizăm LinkEditor pentru a folosi selectionForLink pentru a căuta nodul de link, a randa sub el și pentru a-i actualiza adresa URL.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
Editarea linkului folosind componenta LinkEditor.

Detectarea legăturilor în text

Majoritatea aplicațiilor de procesare de text identifică și convertesc legăturile din interiorul textului în obiecte de legătură. Să vedem cum ar funcționa asta în editor înainte de a începe să-l construim.

Legăturile sunt detectate pe măsură ce utilizatorul le introduce.

Pașii logicii pentru a activa acest comportament ar fi:

  1. Pe măsură ce documentul se modifică odată cu tastarea utilizatorului, găsiți ultimul caracter introdus de utilizator. Dacă acel personaj este un spațiu, știm că trebuie să existe un cuvânt care ar fi putut fi înaintea lui.
  2. Dacă ultimul caracter a fost spațiu, îl marchem ca limită de sfârșit a cuvântului care a venit înaintea lui. Apoi parcurgem înapoi caracter cu caracter în interiorul nodului text pentru a găsi unde a început acel cuvânt. În timpul acestei traversări, trebuie să avem grijă să nu trecem de marginea începutului nodului în nodul anterior.
  3. Odată ce am găsit înainte limitele de început și de sfârșit ale cuvântului, verificăm șirul cuvântului și vedem dacă a fost o adresă URL. Dacă a fost, îl convertim într-un nod de legătură.

Logica noastră trăiește într-o funcție util identifyLinksInTextIfAny care locuiește în EditorUtils și este numită în componenta onChange în Editor .

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

Iată identifyLinksInTextIfAny cu logica pentru Pasul 1 implementată:

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

Există două funcții de ajutor SlateJS care ușurează lucrurile aici.

  • Editor.before — Ne oferă punctul înainte de o anumită locație. Ia unit ca parametru, astfel încât să putem cere caracterul/cuvântul/blocul etc înainte de a trece location .
  • Editor.string — Obține șirul într-un interval.

Ca exemplu, diagrama de mai jos explică ce valori sunt ale acestor variabile atunci când utilizatorul introduce un caracter „E” și cursorul lor se află după el.

Diagrama care explică unde se adresează cursorPoint și startPointOfLastCharacter după pasul 1 cu un exemplu
cursorPoint și startPointOfLastCharacter după Pasul 1 cu un exemplu de text. (Previzualizare mare)

Dacă textul „ABCDE” a fost primul nod de text al primului paragraf din document, valorile noastre de puncte ar fi —

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

Dacă ultimul caracter a fost un spațiu, știm de unde a început - startPointOfLastCharacter. Să trecem la pasul 2, unde ne deplasăm înapoi, caracter cu caracter, până când găsim un alt spațiu sau începutul nodului text în sine.

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

Iată o diagramă care arată unde indică aceste puncte diferite odată ce găsim că ultimul cuvânt introdus este ABCDE .

Diagrama care explică unde sunt punctele diferite după pasul 2 de detectare a legăturii cu un exemplu
Unde sunt puncte diferite după pasul 2 de detectare a legăturii cu un exemplu. (Previzualizare mare)

Rețineți că start și end sunt punctele înainte și după spațiul de acolo. În mod similar, startPointOfLastCharacter și cursorPoint sunt punctele de dinainte și de după spațiul introdus de utilizator. Prin urmare [end,startPointOfLastCharacter] ne oferă ultimul cuvânt inserat.

Înregistrăm valoarea lastWord în consolă și verificăm valorile pe măsură ce scriem.

Consola înregistrează verificând ultimul cuvânt introdus de utilizator după logica din Pasul 2.

Acum că am dedus care a fost ultimul cuvânt pe care l-a tastat utilizatorul, verificăm că a fost într-adevăr o adresă URL și convertim acel interval într-un obiect link. Această conversie arată similar modului în care butonul de link din bara de instrumente a convertit textul selectat de utilizator într-un link.

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

identifyLinksInTextIfAny este numit în onChange din Slate, așa că nu am dori să actualizăm structura documentului în interiorul onChange . Prin urmare, punem această actualizare în coada noastră de sarcini cu un Promise.resolve().then(..) .

Să vedem cum logica se adună în acțiune! Verificăm dacă inserăm link-uri la sfârșitul, la mijloc sau la începutul unui nod text.

Link-uri detectate pe măsură ce utilizatorul le introduce.

Cu asta, am împachetat funcționalitățile pentru link-uri din editor și am trecut la Imagini.

Manipularea imaginilor

În această secțiune, ne concentrăm pe adăugarea de suport pentru a reda nodurile de imagine, adăugarea de noi imagini și actualizarea legendelor imaginilor. Imaginile, în structura documentului nostru, ar fi reprezentate ca noduri Void. Nodurile Void din SlateJS (analog cu elementele Void din specificațiile HTML) sunt astfel încât conținutul lor nu este text editabil. Acest lucru ne permite să redăm imaginile ca goluri. Datorită flexibilității Slate în ceea ce privește randarea, ne putem reda în continuare propriile elemente editabile în interiorul elementelor Void - pe care le vom face pentru editarea subtitrărilor imaginii. SlateJS are un exemplu care demonstrează cum puteți încorpora un întreg editor de text îmbogățit într-un element Void.

Pentru a reda imagini, configurăm editorul să trateze imaginile ca elemente Void și să furnizeze o implementare de randare a modului în care imaginile ar trebui să fie redate. Adăugăm o imagine la ExampleDocument și verificăm dacă este redată corect cu legenda.

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

Două lucruri de reținut atunci când încercați să redați noduri void cu SlateJS:

  • Elementul DOM rădăcină ar trebui să aibă setat contentEditable contentEditable={false} , astfel încât SlateJS să-și trateze conținutul astfel. Fără aceasta, pe măsură ce interacționați cu elementul void, SlateJS poate încerca să calculeze selecții etc. și, ca rezultat, să rupă.
  • Chiar dacă nodurile Void nu au niciun nod copil (cum ar fi nodul nostru de imagine ca exemplu), trebuie totuși să redăm children și să furnizăm un nod text gol ca copil (a se vedea ExampleDocument mai jos), care este tratat ca un punct de selecție al Void-ului. element de SlateJS

Acum actualizăm ExampleDocument pentru a adăuga o imagine și pentru a verifica dacă apare cu legenda în 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: "" }], }, ]; 
Imagine redată în Editor
Imagine redată în Editor. (Previzualizare mare)

Acum să ne concentrăm pe editarea subtitrărilor. Modul în care dorim ca aceasta să fie o experiență perfectă pentru utilizator este că atunci când face clic pe legendă, arătăm o intrare de text în care poate edita legendă. Dacă dau clic în afara introducerii sau apăsează tasta RETURN, o tratăm ca pe o confirmare pentru aplicarea legendei. Apoi actualizăm legenda de pe nodul de imagine și comutăm legenda înapoi în modul de citire. Să-l vedem în acțiune, ca să avem o idee despre ceea ce construim.

Editarea subtitrării imaginii în acțiune.

Să actualizăm componenta Imagine pentru a avea o stare pentru modurile de citire și editare ale subtitrărilor. Actualizăm starea subtitrare locală pe măsură ce utilizatorul o actualizează și când dă clic pe ( onBlur ) sau apăsează RETURN ( onKeyDown ), aplicăm legenda nodului și comutăm din nou în modul citire.

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

Cu aceasta, funcționalitatea de editare a legendelor este completă. Acum trecem la adăugarea unei modalități prin care utilizatorii pot încărca imagini în editor. Să adăugăm un buton din bara de instrumente care le permite utilizatorilor să selecteze și să încarce o imagine.

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

Pe măsură ce lucrăm cu încărcări de imagini, codul ar putea crește destul de mult, așa că mutăm gestionarea încărcării imaginii la un hook useImageUploadHandler care dă un apel invers atașat elementului de intrare fișier. Vom discuta în scurt timp despre motivul pentru care are nevoie de starea previousSelection de selecție.

Înainte de a implementa useImageUploadHandler , vom configura serverul pentru a putea încărca o imagine. Configuram un server Express și instalăm alte două pachete - cors și multer care se ocupă de încărcarea fișierelor pentru noi.

 yarn add express cors multer

Adăugăm apoi un script src/server.js care configurează serverul Express cu cors și multer și expune un punct final /upload în care vom încărca imaginea.

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

Acum că avem configurarea serverului, ne putem concentra pe gestionarea încărcării imaginii. Când utilizatorul încarcă o imagine, ar putea trece câteva secunde înainte ca imaginea să fie încărcată și avem o adresă URL pentru aceasta. Cu toate acestea, facem ceea ce este pentru a oferi utilizatorului feedback imediat că încărcarea imaginii este în curs, astfel încât să știe că imaginea este inserată în editor. Iată pașii pe care îi implementăm pentru ca acest comportament să funcționeze -

  1. Odată ce utilizatorul selectează o imagine, inserăm un nod de imagine la poziția cursorului utilizatorului cu un steag isUploading setat pe acesta, astfel încât să putem arăta utilizatorului o stare de încărcare.
  2. Trimitem cererea către server pentru a încărca imaginea.
  3. Odată ce solicitarea este completă și avem o adresă URL a imaginii, o setăm pe imagine și eliminăm starea de încărcare.

Să începem cu primul pas în care inserăm nodul imagine. Acum, partea dificilă aici este că ne confruntăm cu aceeași problemă cu selecția ca și cu butonul de link din bara de instrumente. De îndată ce utilizatorul face clic pe butonul Imagine din bara de instrumente, editorul își pierde focalizarea și selecția devine null . Dacă încercăm să inserăm o imagine, nu știm unde a fost cursorul utilizatorului. Urmărirea selectiei previousSelection ne oferă acea locație și o folosim pentru a insera nodul.

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

Pe măsură ce inserăm noul nod de imagine, îi atribuim și un id de identificare folosind pachetul uuid. Vom discuta în implementarea Pasului (3) de ce avem nevoie de asta. Acum actualizăm componenta imagine pentru a folosi steagul isUploading pentru a afișa o stare de încărcare.

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

Aceasta completează implementarea pasului 1. Să verificăm că putem selecta o imagine de încărcat, vedem că nodul de imagine este inserat cu un indicator de încărcare acolo unde a fost inserat în document.

Încărcarea imaginii creând un nod de imagine cu starea de încărcare.

Trecând la Pasul (2), vom folosi biblioteca axois pentru a trimite o solicitare către 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 }); }, [...]); }

Verificăm că încărcarea imaginii funcționează și imaginea apare în folderul public/photos al aplicației. Acum că încărcarea imaginii este completă, trecem la Pasul (3) unde vrem să setăm adresa URL a imaginii în funcția resolve() a promisiunii axios. Am putea actualiza imaginea cu Transforms.setNodes , dar avem o problemă — nu avem calea către nodul de imagine nou inserat. Să vedem care sunt opțiunile noastre pentru a ajunge la acea imagine —

  • Nu putem folosi editor.selection deoarece selecția trebuie să fie pe nodul de imagine nou inserat? Nu putem garanta acest lucru, deoarece în timpul încărcării imaginii, este posibil ca utilizatorul să fi făcut clic în altă parte și s-ar putea să se fi schimbat selecția.
  • Ce zici de folosirea previousSelection pe care am folosit-o pentru a insera nodul de imagine în primul rând? Din același motiv, nu putem folosi editor.selection , nu putem folosi previousSelection deoarece s-ar putea să se fi schimbat și el.
  • SlateJS are un modul Istoric care urmărește toate modificările care au loc în document. Am putea folosi acest modul pentru a căuta în istoric și a găsi ultimul nod de imagine inserat. De asemenea, acest lucru nu este complet de încredere dacă încărcarea imaginii a durat mai mult și utilizatorul a inserat mai multe imagini în diferite părți ale documentului înainte de finalizarea primei încărcări.
  • În prezent, API-ul lui Transform.insertNodes nu returnează nicio informație despre nodurile inserate. Dacă ar putea returna căile către nodurile inserate, le-am putea folosi pentru a găsi nodul precis al imaginii pe care ar trebui să îl actualizăm.

Deoarece niciuna dintre abordările de mai sus nu funcționează, aplicăm un id nodului de imagine inserat (în Pasul (1)) și folosim din nou același id pentru a-l localiza când încărcarea imaginii este finalizată. Cu asta, codul nostru pentru Pasul (3) arată ca mai jos -

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

Odată cu finalizarea implementării tuturor celor trei pași, suntem gata să testăm încărcarea imaginii cap la cap.

Încărcarea imaginii funcționează de la capăt la capăt

Cu asta, am împachetat imagini pentru editorul nostru. În prezent, arătăm o stare de încărcare de aceeași dimensiune, indiferent de imagine. Aceasta ar putea fi o experiență tulburătoare pentru utilizator dacă starea de încărcare este înlocuită cu o imagine drastic mai mică sau mai mare la finalizarea încărcării. O bună continuare a experienței de încărcare este obținerea dimensiunilor imaginii înainte de încărcare și afișarea unui substituent de aceeași dimensiune, astfel încât tranziția să fie fără întreruperi. Cârligul pe care îl adăugăm mai sus ar putea fi extins pentru a sprijini alte tipuri de media, cum ar fi videoclipuri sau documente, și pentru a reda și acele tipuri de noduri.

Concluzie

În acest articol, am creat un editor WYSIWYG care are un set de bază de funcționalități și câteva micro-experiențe de utilizator, cum ar fi detectarea link-urilor, editarea link-urilor la loc și editarea subtitrărilor imaginilor, care ne-au ajutat să aprofundăm SlateJS și conceptele de editare a textului îmbogățit în general. Dacă acest spațiu cu probleme din jurul Editării textului îmbogățit sau al procesării de text vă interesează, unele dintre problemele interesante de urmat ar putea fi:

  • Colaborare
  • O experiență mai bogată de editare a textului, care acceptă alinierea textului, imaginile inline, copierea-lipirea, schimbarea fontului și a culorilor textului etc.
  • Importul din formate populare, cum ar fi documente Word și Markdown.

Dacă doriți să aflați mai multe SlateJS, iată câteva link-uri care ar putea fi de ajutor.

  • Exemple SlateJS
    O mulțime de exemple care depășesc elementele de bază și construiesc funcționalități care se găsesc de obicei în editori precum Căutare și Evidențiere, Previzualizare Markdown și Mențiuni.
  • Documente API
    Referire la o mulțime de funcții de ajutor expuse de SlateJS pe care ar putea dori să le păstrați la îndemână atunci când încercați să efectuați interogări/transformări complexe pe obiecte SlateJS.

În cele din urmă, Slack Channel de la SlateJS este o comunitate foarte activă de dezvoltatori web care construiesc aplicații de editare a textului îmbogățit folosind SlateJS și un loc minunat pentru a afla mai multe despre bibliotecă și pentru a obține ajutor dacă este necesar.