Fermer

mai 21, 2021

Créer un éditeur de texte enrichi (WYSIWYG) à partir de zéro


À propos de l'auteur

Shalabh Vyas est un ingénieur Front-End qui a l'expérience de travailler tout au long du cycle de vie de développement de produits en lançant des applications Web riches. …
En savoir plus sur
Shalabh

Dans cet article, nous allons apprendre à créer un éditeur WYSIWYG / Rich-Text qui prend en charge le texte riche, les images, les liens et certaines fonctionnalités nuancées des applications de traitement de texte. Nous utiliserons SlateJS pour construire le shell de l'éditeur puis ajouterons une barre d'outils et des configurations personnalisées. Le code de l'application est disponible sur GitHub pour référence

Ces dernières années, le domaine de la création et de la représentation de contenu sur les plates-formes numériques a connu une perturbation massive. Le succès généralisé de produits tels que Quip, Google Docs et Dropbox Paper a montré à quel point les entreprises s'efforcent de créer la meilleure expérience pour les créateurs de contenu dans le domaine des entreprises et tentent de trouver des moyens innovants de briser les moules traditionnels de partage et de consommation du contenu. Profitant de la portée massive des plates-formes de médias sociaux, il y a une nouvelle vague de créateurs de contenu indépendants qui utilisent des plates-formes comme Medium pour créer du contenu et le partager avec leur public.

Comme tant de personnes de professions et d'horizons différents essaient de créer du contenu. sur ces produits, il est important que ces produits fournissent une expérience performante et transparente de création de contenu et disposent d'équipes de concepteurs et d'ingénieurs qui développent un certain niveau d'expertise dans le domaine au fil du temps dans cet espace. Avec cet article, nous essayons non seulement de jeter les bases de la création d'un éditeur, mais aussi de donner aux lecteurs un aperçu de la façon dont de petites pépites de fonctionnalités lorsqu'elles sont réunies peuvent créer une expérience utilisateur exceptionnelle pour un créateur de contenu.

Understanding The Document Structure

Avant de plonger dans la construction de l'éditeur, regardons comment un document est structuré pour un éditeur de texte enrichi et quels sont les différents types de structures de données impliqués.

Noeuds de document

Les nœuds de document sont utilisés pour représenter le contenu du document. Les types courants de nœuds qu'un document de texte enrichi peut contenir sont les paragraphes, les titres, les images, les vidéos, les blocs de code et les guillemets. Certains d'entre eux peuvent contenir d'autres nœuds en tant qu'enfants à l'intérieur (par exemple, les nœuds de paragraphe contiennent des nœuds de texte à l'intérieur). Les nœuds contiennent également toutes les propriétés spécifiques à l'objet qu'ils représentent qui sont nécessaires pour rendre ces nœuds dans l'éditeur. (par exemple, les nœuds d'image contiennent une propriété image src les blocs de code peuvent contenir une propriété de langage et ainsi de suite).

Il existe en grande partie deux types de nœuds qui représentent comment ils devraient être rendu –

  • Nœuds de bloc (analogue au concept HTML des éléments de niveau bloc) qui sont chacun rendus sur une nouvelle ligne et occupent la largeur disponible. Les nœuds de bloc peuvent contenir d'autres nœuds de bloc ou des nœuds en ligne à l'intérieur. Une observation ici est que les nœuds de niveau supérieur d'un document seraient toujours des nœuds de bloc.
  • Inline Nodes (analogue au concept HTML des éléments Inline) dont le rendu commence sur la même ligne que le nœud précédent. Il existe certaines différences dans la façon dont les éléments en ligne sont représentés dans différentes bibliothèques d'édition. SlateJS permet aux éléments en ligne d'être eux-mêmes des nœuds. DraftJS, une autre bibliothèque d'édition de texte enrichi populaire, vous permet d'utiliser le concept d'entités pour rendre des éléments en ligne. Les liens et les images en ligne sont des exemples de nœuds en ligne.
  • Void Nodes – SlateJS autorise également cette troisième catégorie de nœuds que nous utiliserons plus loin dans cet article pour rendre les médias.

Si vous voulez en savoir plus sur ces catégories, SlateJS's la documentation sur Nodes est un bon point de départ.

Attributs

Semblable au concept HTML des attributs, les attributs d'un document Rich Text sont utilisés pour représenter les propriétés non liées au contenu d'un nœud ou de ses enfants . Par exemple, un nœud de texte peut avoir des attributs de style de caractère qui nous indiquent si le texte est en gras / italique / souligné, etc. Bien que cet article représente les en-têtes comme des nœuds eux-mêmes, une autre façon de les représenter pourrait être que les nœuds aient des styles de paragraphe ( paragraphe & h1-h6 ) comme attributs sur eux.

Ci-dessous l'image donne un exemple de la façon dont la structure d'un document (en JSON) est décrite à un niveau plus granulaire à l'aide de nœuds et d'attributs mettant en évidence certains des éléments de la structure à gauche.

 Image montrant un exemple de document dans l'éditeur avec sa structure représentation à gauche
Exemple de document et sa représentation structurelle. ( Grand aperçu )

Certaines des choses qui méritent d'être signalées ici avec la structure sont:

  • Les nœuds de texte sont représentés par {text: 'text content'}
  • ] Les propriétés des nœuds sont stockées directement sur le nœud (par exemple, url pour les liens et légende pour les images)
  • La représentation spécifique à SlateJS des attributs de texte divise les nœuds de texte en leur propres nœuds si le style de caractère change. Par conséquent, le texte « Duis aute irure dolor » est un nœud de texte qui lui est propre avec bold: true placé dessus. Il en va de même pour le texte en italique, souligné et code de ce document.

Emplacements et sélection

Lors de la création d'un éditeur de texte enrichi, il est essentiel de comprendre comment la partie la plus granulaire d'un document ( disons un caractère) peut être représenté avec une sorte de coordonnées. Cela nous aide à naviguer dans la structure du document au moment de l'exécution pour comprendre où nous en sommes dans la hiérarchie des documents. Plus important encore, les objets de localisation nous donnent un moyen de représenter la sélection de l'utilisateur qui est assez largement utilisé pour personnaliser l'expérience utilisateur de l'éditeur en temps réel. Nous utiliserons la sélection pour créer notre barre d'outils plus loin dans cet article. Voici quelques exemples:

  • Le curseur de l'utilisateur est-il actuellement à l'intérieur d'un lien, peut-être devrions-nous lui montrer un menu pour modifier / supprimer le lien?
  • L'utilisateur a-t-il sélectionné une image? Peut-être que nous leur donnons un menu pour redimensionner l'image.
  • Si l'utilisateur sélectionne un certain texte et clique sur le bouton SUPPRIMER, nous déterminons quel était le texte sélectionné par l'utilisateur et le supprimons du document.

Document de SlateJS sur Emplacement explique en détail ces structures de données, mais nous les parcourons ici rapidement car nous utilisons ces termes à différentes instances de l'article et montrons un exemple dans le diagramme qui suit.

  • Chemin
    Représenté par un tableau de nombres, un chemin est le moyen d'accéder à un nœud dans le document. Par exemple, un chemin [2,3] représente le 3ème nœud enfant du 2ème nœud du document.
  • Point
    Emplacement plus granulaire du contenu représenté par chemin + décalage. Par exemple, un point de {chemin: [2,3]offset: 14} représente le 14ème caractère du 3ème nœud enfant à l'intérieur du 2ème nœud du document.
  • Range
    Une paire de points (appelés ancre et focus ) qui représentent une plage de texte à l'intérieur du document. Ce concept provient de l'API de sélection Web ancre est l'endroit où la sélection de l'utilisateur a commencé et focus est l'endroit où elle s'est terminée. Une plage / sélection réduite indique où les points d'ancrage et de mise au point sont identiques (pensez à un curseur clignotant dans une entrée de texte par exemple).

À titre d'exemple, disons que la sélection de l'utilisateur dans notre exemple de document ci-dessus est ipsum :

 Image avec le texte «ipsum» sélectionné dans l'éditeur
L'utilisateur sélectionne le mot ipsum . ( Grand aperçu )

La sélection de l'utilisateur peut être représentée comme suit:

 {
  ancre: {chemin: [2,0]offset: 5}, / * 0ème nœud de texte à l'intérieur du nœud de paragraphe qui lui-même est l'index 2 dans le document * /
  focus: {chemin: [2,0]décalage: 11}, // espace + 'ipsum'
} `

Configuration de l'éditeur

Dans cette section, nous allons configurer l'application et installer un éditeur de texte riche de base avec SlateJS. L'application standard serait create-react-app avec des dépendances SlateJS ajoutées. Nous construisons l'interface utilisateur de l'application en utilisant des composants de react-bootstrap . Commençons!

Créez un dossier appelé wysiwyg-editor et exécutez la commande ci-dessous depuis le répertoire pour configurer l'application react. Nous exécutons ensuite une commande yarn start qui devrait lancer le serveur Web local (le port par défaut est 3000) et vous montrer un écran de bienvenue React.

 npx create-react-app.
début de fil

Nous allons ensuite ajouter les dépendances SlateJS à l'application.

 yarn add slate slate-react

slate est le package principal de SlateJS et slate-react comprend l'ensemble des composants React que nous utiliserons pour rendre les éditeurs Slate. SlateJS expose d'autres packages organisés par fonctionnalité que l'on pourrait envisager d'ajouter à leur éditeur.

Nous créons d'abord un dossier utils qui contient tous les modules utilitaires que nous créons dans cette application. Nous commençons par créer un ExampleDocument.js qui renvoie une structure de document de base contenant un paragraphe avec du texte. Ce module ressemble à ceci:

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

export par défaut ExampleDocument;

Nous ajoutons maintenant un dossier appelé components qui contiendra tous nos composants React et procédons comme suit:

  • Ajoutez notre premier composant React Editor.js . Il ne renvoie qu'un div pour le moment.
  • Mettez à jour le composant App.js pour maintenir le document dans son état initialisé à notre ExampleDocument ci-dessus.
  • Rendez l'éditeur dans l'application et passez l'état du document et un gestionnaire onChange à l'éditeur afin que l'état de notre document soit mis à jour au fur et à mesure que l'utilisateur le met à jour.
  • Nous utilisons les composants Nav de React bootstrap pour ajoutez également une barre de navigation à l'application.

Le composant App.js ressemble maintenant à ceci:

 import Editor from './components/Editor';

function App () {
  const [document, updateDocument] = useState (ExampleDocument);

  revenir (
    <>
      
        
           {""}
          Éditeur WYSIWYG
        
      
      
);

Dans le composant Editor, nous instancions ensuite l'éditeur SlateJS et le maintenons dans un useMemo afin que l'objet ne change pas entre les rendus.

 // dépendances importées comme ci-dessous.
import {withReact} de "slate-react";
import {createEditor} depuis "slate";

éditeur const = useMemo (() => withReact (createEditor ()), []);

createEditor nous donne l'instance de l'éditeur SlateJS que nous utilisons largement via l'application pour accéder aux sélections, exécuter des transformations de données, etc. withReact est un plugin SlateJS qui ajoute des comportements React et DOM à l'objet éditeur. SlateJS Plugins sont des fonctions Javascript qui reçoivent l'objet editor et lui associent une configuration. Cela permet aux développeurs Web d'ajouter des configurations à leur instance d'éditeur SlateJS d'une manière composable.

Nous importons et rendons maintenant les composants et de SlateJS avec le document prop que nous obtenons d'App.js. Slate expose un tas de contextes React que nous utilisons pour accéder dans le code de l'application. Editable est le composant qui restitue la hiérarchie du document pour l'édition. Dans l'ensemble, le module Editor.js à ce stade ressemble à ceci:

 import {Editable, Slate, withReact} from "slate-react";

import {createEditor} depuis "slate";
import {useMemo} de "react";

Exporter l'éditeur de fonctions par défaut ({document, onChange}) {
  éditeur const = useMemo (() => withReact (createEditor ()), []);
  revenir (
    
      
    
  );
}

À ce stade, nous avons ajouté les composants React nécessaires et l'éditeur rempli d'un exemple de document. Notre éditeur devrait maintenant être configuré pour nous permettre de taper et de modifier le contenu en temps réel – comme dans le screencast ci-dessous.

Basic Editor Setup in action

Passons maintenant à la section suivante où nous configurons le éditeur pour rendre les styles de caractères et les nœuds de paragraphe.

RENDU DE TEXTE PERSONNALISÉ ET BARRE D'OUTILS

Nœuds de style de paragraphe

Actuellement, notre éditeur utilise le rendu par défaut de SlateJS pour tous les nouveaux types de nœuds que nous pouvons ajouter au document. Dans cette section, nous voulons pouvoir rendre les nœuds d'en-tête. Pour ce faire, nous fournissons un accessoire de fonction renderElement aux composants de Slate. Cette fonction est appelée par Slate au moment de l'exécution lorsqu'il tente de traverser l'arborescence du document et de rendre chaque nœud. La fonction renderElement obtient trois paramètres –

  • attributs
    SlateJS spécifiques qui doivent être appliqués à l'élément DOM de niveau supérieur renvoyé par cette fonction.
  • element
    Le nœud objet lui-même tel qu'il existe dans la structure du document
  • children
    Les enfants de ce nœud tels que définis dans la structure du document.

Nous ajoutons notre implémentation renderElement à un hook appelé useEditorConfig où nous ajouterons plus de configurations d'éditeur au fur et à mesure. Nous utilisons ensuite le hook sur l'instance de l'éditeur dans Editor.js .

 import {DefaultElement} de "slate-react";

fonction d'exportation par défaut useEditorConfig (éditeur) {
  return {renderElement};
}

function renderElement (accessoires) {
  const {élément, enfants, attributs} = accessoires;
  commutateur (element.type) {
    cas "paragraphe":
      retour 

{enfants}

; cas "h1": return

{enfants}

; cas "h2": retour

{enfants}

; cas "h3": retour

{enfants}

; cas "h4": return

{enfants}

; défaut: // Pour le cas par défaut, nous déléguons au rendu par défaut de Slate. retour ; } }

Puisque cette fonction nous donne accès à l'élément (qui est le nœud lui-même), nous pouvons personnaliser renderElement pour implémenter un rendu plus personnalisé qui ne se contente pas de vérifier ] element.type . Par exemple, vous pourriez avoir un nœud d'image qui a une propriété isInline que nous pourrions utiliser pour renvoyer une structure DOM différente qui nous aide à rendre les images en ligne par rapport aux images de bloc.

Nous mettons maintenant à jour le composant Editor pour utiliser ce hook comme ci-dessous:

 const {renderElement} = useEditorConfig (editor);

revenir (
    ...
    
);

Une fois le rendu personnalisé en place, nous mettons à jour le ExampleDocument pour inclure nos nouveaux types de nœuds et vérifier qu'ils s'affichent correctement dans l'éditeur.

 const ExampleDocument = [
  {
    type: "h1",
    children: [{ text: "Heading 1" }],
  },
  {
    tapez: "h2",
    enfants: [{ text: "Heading 2" }],
  },
 // ... plus de nœuds d'en-tête
 Image montrant différents en-têtes et nœuds de paragraphes rendus dans l'éditeur
En-têtes et nœuds de paragraphes dans l'éditeur. ( Grand aperçu )

Styles de caractères

Semblable à renderElement SlateJS propose un accessoire de fonction appelé renderLeaf qui peut être utilisé pour personnaliser le rendu des nœuds de texte ( ] Leaf faisant référence aux nœuds de texte qui sont les nœuds de niveau inférieur / feuilles de l'arborescence du document). En suivant l'exemple de renderElement nous écrivons une implémentation pour renderLeaf .

 fonction d'exportation par défaut useEditorConfig (éditeur) {
  return {renderElement, renderLeaf};
}

// ...
function renderLeaf ({attributs, enfants, feuille}) {
  let el = <> {enfants} ;

  if (leaf.bold) {
    el =  {el} ;
  }

  if (code feuille) {
    el =  {el} ;
  }

  if (leaf.italic) {
    el =  {el} ;
  }

  if (feuille.underline) {
    el =  {el} ;
  }

  return  {el} ;
}

Une observation importante de l'implémentation ci-dessus est qu'elle nous permet de respecter la sémantique HTML pour les styles de caractères. Puisque renderLeaf nous donne accès au nœud de texte leaf lui-même, nous pouvons personnaliser la fonction pour implémenter un rendu plus personnalisé. Par exemple, vous pourriez avoir un moyen de permettre aux utilisateurs de choisir un highlightColor pour le texte et de vérifier cette propriété de feuille ici pour attacher les styles respectifs.

Nous mettons maintenant à jour le composant Editor pour utiliser ce qui précède, le ExampleDocument pour avoir quelques nœuds de texte dans le paragraphe avec des combinaisons de ces styles et vérifier qu'ils sont rendus comme prévu dans l'éditeur avec les balises sémantiques que nous avons utilisées.

 # src / components / Editor.js

const {renderElement, renderLeaf} = useEditorConfig (éditeur);

revenir (
    ...
    
);
 # src / utils / ExampleDocument.js

{
    type: "paragraphe",
    enfants: [
      { 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 },
    ],
  },
 Styles de caractère dans l'interface utilisateur et comment ils sont rendus dans l'arborescence DOM
Styles de caractère dans l'interface utilisateur et comment ils sont rendus dans l'arborescence DOM. ( Grand aperçu )

Ajout d'une barre d'outils

Commençons par ajouter un nouveau composant Toolbar.js auquel nous ajoutons quelques boutons pour les styles de caractères et une liste déroulante pour les paragraphes styles et nous les câblerons plus tard dans la section.

 const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"];
const CHARACTER_STYLES = ["bold", "italic", "underline", "code"];

Exporter la barre d'outils de la fonction par défaut ({selection, previousSelection}) {
  revenir (
    
{/ * Liste déroulante des styles de paragraphe * /} {PARAGRAPH_STYLES.map ((blockType) => ( {getLabelForBlockStyle (blockType)} ))} {/ * Boutons pour les styles de caractères * /} {CHARACTER_STYLES.map ((style) => ( <ToolBarButton clé = {style} icon = {} isActive = {false} /> ))}
); } function ToolBarButton (accessoires) { const {icon, isActive, ... otherProps} = accessoires; revenir ( ); }

Nous résumons les boutons du composant ToolbarButton qui est un wrapper autour du composant React Bootstrap Button. Nous rendons ensuite la barre d'outils au-dessus du composant Editable dans Editor et vérifions que la barre d'outils apparaît dans l'application.

 Image montrant la barre d'outils avec des boutons rendus au-dessus de l'éditeur
Toolbar avec des boutons ( Grand aperçu )

Voici les trois fonctionnalités clés que la barre d'outils doit prendre en charge:

  1. Lorsque le curseur de l'utilisateur est à un certain endroit du document et qu'il clique sur l'un des caractères boutons de style, nous devons changer le style du texte qu'il peut saisir ensuite.
  2. Lorsque l'utilisateur sélectionne une plage de texte et clique sur l'un des boutons de style de caractère, nous devons changer le style de cette section spécifique.
  3. ] Lorsque l'utilisateur sélectionne une plage de texte, nous voulons mettre à jour la liste déroulante de style de paragraphe pour refléter le type de paragraphe de la sélection. S'ils sélectionnent une valeur différente de la sélection, nous voulons mettre à jour le style de paragraphe de toute la sélection pour qu'il corresponde à ce qu'ils ont sélectionné.

Voyons comment ces fonctionnalités fonctionnent sur l'éditeur avant de commencer à les implémenter.

Comportement de basculement des styles de caractères

Écoute de la sélection

La chose la plus importante dont la barre d'outils a besoin pour pouvoir exécuter les fonctions ci-dessus est l'état de sélection du document. Au moment d'écrire cet article, SlateJS n'expose pas de méthode onSelectionChange qui pourrait nous donner le dernier état de sélection du document. Cependant, à mesure que la sélection change dans l'éditeur, SlateJS appelle la méthode onChange même si le contenu du document n'a pas changé. Nous l’utilisons pour être averti du changement de sélection et le stocker dans l’état du composant Editor . Nous résumons cela à un hook useSelection où nous pourrions faire une mise à jour plus optimale de l'état de sélection. Ceci est important car la sélection est une propriété qui change assez souvent pour une instance de l'éditeur WYSIWYG.

 import areEqual from "deep-equal";

fonction d'exportation par défaut useSelection (éditeur) {
  const [selection, setSelection] = useState (editor.selection);
  const setSelectionOptimized = useCallback (
    (newSelection) => {
      // ne met pas à jour l'état du composant si la sélection n'a pas changé.
      if (areEqual (selection, newSelection)) {
        revenir;
      }
      setSelection (newSelection);
    },
    [setSelection, selection]
  );

  retour [selection, setSelectionOptimized];
}

Nous utilisons ce hook dans le composant Editor comme ci-dessous et passons la sélection au composant Toolbar.

 const [selection, setSelection] = useSelection (editor);

  const onChangeHandler = useCallback (
    (document) => {
      onChange (document);
      setSelection (editor.selection);
    },
    [editor.selection, onChange, setSelection]
  );

  revenir (
    
        
         ...
Considérations relatives aux performances

Dans une application où nous avons une base de code d'éditeur beaucoup plus grande avec beaucoup plus de fonctionnalités, il est important de stocker et d'écouter les changements de sélection de manière performante (comme l'utilisation d'une bibliothèque de gestion d'état) en écoutant les composants les changements de sélection sont susceptibles de s'afficher trop souvent. Une façon de faire cela consiste à avoir des sélecteurs optimisés au-dessus de l'état de sélection qui contiennent des informations de sélection spécifiques. Par exemple, un éditeur peut souhaiter rendre un menu de redimensionnement d'image lorsqu'une image est sélectionnée. Dans un tel cas, il peut être utile d’avoir un sélecteur isImageSelected calculé à partir de l’état de sélection de l’éditeur et le menu Image ne sera restitué que lorsque la valeur de ce sélecteur change. Reselect de Redux est l'une de ces bibliothèques qui active les sélecteurs de construction.

Nous n'utilisons pas la sélection dans la barre d'outils jusqu'à plus tard, mais en la transmettant comme accessoire, la barre d'outils est à nouveau rendue chaque fois que la sélection change dans l'éditeur. Nous faisons cela parce que nous ne pouvons pas nous fier uniquement à la modification du contenu du document pour déclencher un nouveau rendu sur la hiérarchie ( App -> Editor -> Toolbar ) car les utilisateurs peuvent simplement continuer à cliquer autour du document, modifiant ainsi la sélection, mais ne jamais changer réellement le contenu du document lui-même.

Basculer les styles de caractères

Nous passons maintenant à obtenir quels sont les styles de caractères actifs de SlateJS et à les utiliser dans l'éditeur. Ajoutons un nouveau module JS EditorUtils qui hébergera toutes les fonctions util que nous construisons à l'avenir pour obtenir / faire des choses avec SlateJS. Notre première fonction dans le module est getActiveStyles qui donne un Set de styles actifs dans l'éditeur. Nous ajoutons également une fonction pour basculer un style sur la fonction d'édition – toggleStyle :

 # src / utils / EditorUtils.js

importer {Editor} depuis "slate";

fonction d'exportation getActiveStyles (éditeur) {
  return new Set (Object.keys (Editor.marks (editor) ?? {}));
}

fonction d'exportation toggleStyle (éditeur, style) {
  const activeStyles = getActiveStyles (éditeur);
  if (activeStyles.has (style)) {
    Editor.removeMark (éditeur, style);
  } autre {
    Editor.addMark (éditeur, style, vrai);
  }
}

Les deux fonctions utilisent l'objet editor qui est l'instance Slate comme paramètre, tout comme beaucoup de fonctions util que nous ajoutons plus tard dans l'article. Dans la terminologie Slate, les styles de formatage sont appelés Marks et nous utilisons méthodes d'aide sur l'interface Editor pour obtenir, ajouter et supprimer ces marques. Nous importons ces fonctions util dans la barre d'outils et les connectons aux boutons que nous avons ajoutés précédemment.

 # src / components / Toolbar.js

import {getActiveStyles, toggleStyle} de "../utils/EditorUtils";
import {useEditor} de "slate-react";

Exporter la barre d'outils de la fonction par défaut ({selection}) {
  éditeur const = useEditor ();

retour 
( <ToolBarButton clé = {style} characterStyle = {style} icon = {} isActive = {getActiveStyles (éditeur) .has (style)} onMouseDown = {(événement) => { event.preventDefault (); toggleStyle (éditeur, style); }} /> ))}

useEditor est un crochet Slate qui nous donne accès à l'instance Slate à partir du contexte où elle a été attachée par le composant & lt; Slate> plus haut dans la hiérarchie de rendu. [19659006] On pourrait se demander pourquoi nous utilisons ici onMouseDown au lieu de onClick ? Il existe un problème Github sur la façon dont Slate transforme la sélection en null lorsque l'éditeur perd le focus de quelque manière que ce soit. Ainsi, si nous attachons les gestionnaires onClick aux boutons de notre barre d'outils, la sélection devient null et les utilisateurs perdent la position de leur curseur en essayant de basculer un style qui n'est pas génial vivre. À la place, nous basculons le style en attachant un événement onMouseDown qui empêche la sélection de se réinitialiser. Une autre façon de faire est de garder une trace de la sélection nous-mêmes afin que nous sachions quelle était la dernière sélection et que nous l'utilisions pour basculer les styles. Nous introduisons le concept de previousSelection plus loin dans l'article mais pour résoudre un problème différent.

SlateJS nous permet de configurer des gestionnaires d'événements sur l'éditeur. Nous utilisons cela pour câbler les raccourcis clavier pour basculer les styles de caractères. Pour ce faire, nous ajoutons un objet KeyBindings à l'intérieur de useEditorConfig où nous exposons un gestionnaire d'événements onKeyDown attaché au composant Editable . Nous utilisons l'utilitaire is-hotkey pour déterminer la combinaison de touches et basculer le style correspondant.

 # src / hooks / useEditorConfig.js

fonction d'exportation par défaut useEditorConfig (éditeur) {
  const onKeyDown = useCallback (
    (événement) => KeyBindings.onKeyDown (éditeur, événement),
    [editor]
  );
  return {renderElement, renderLeaf, onKeyDown};
}

const KeyBindings = {
  onKeyDown: (éditeur, événement) => {
    if (isHotkey ("mod + b", événement)) {
      toggleStyle (éditeur, "gras");
      revenir;
    }
    if (isHotkey ("mod + i", événement)) {
      toggleStyle (éditeur, "italique");
      revenir;
    }
    if (isHotkey ("mod + c", événement)) {
      toggleStyle (éditeur, "code");
      revenir;
    }
    if (isHotkey ("mod + u", événement)) {
      toggleStyle (éditeur, "souligné");
      revenir;
    }
  },
};

# src / components / Editor.js
...
 
Les styles de caractères sont activés à l’aide de raccourcis clavier.

Fonctionnement de la liste déroulante des styles de paragraphe

Passons maintenant au fonctionnement de la liste déroulante Styles de paragraphes. De la même manière que les listes déroulantes de style paragraphe fonctionnent dans les applications de traitement de texte populaires telles que MS Word ou Google Docs, nous voulons que les styles des blocs de premier niveau dans la sélection de l'utilisateur soient reflétés dans la liste déroulante. S'il existe un seul style cohérent dans la sélection, nous mettons à jour la valeur de la liste déroulante pour être celle-ci. S'il y en a plusieurs, nous définissons la valeur du menu déroulant sur "Multiple". Ce comportement doit fonctionner à la fois pour les sélections réduites et développées.

Pour implémenter ce comportement, nous devons être en mesure de trouver les blocs de niveau supérieur couvrant la sélection de l'utilisateur. Pour ce faire, nous utilisons Slate Editor.nodes – Une fonction d'assistance couramment utilisée pour rechercher des nœuds dans un arbre filtré par différentes options.

 nodes (
    éditeur: éditeur,
    options?: {
      à ?: Emplacement | Portée
      match?: NodeMatch 
      mode?: 'tout' | «le plus élevé» | 'le plus bas'
      universal?: booléen
      reverse?: booléen
      vides?: booléen
    }
  ) => Générateur <NodeEntry void, undefined>

La fonction d'assistance prend une instance Editor et un objet options qui est un moyen de filtrer les nœuds dans l'arborescence lorsqu'elle la traverse. La fonction renvoie un générateur de NodeEntry . Un NodeEntry dans la terminologie Slate est un tuple d'un nœud et son chemin d'accès – [node, pathToNode]. Les options trouvées ici sont disponibles sur la plupart des fonctions d'assistance Slate. Voyons ce que chacun de ces moyens signifie:

  • at
    Cela peut être un chemin / un point / une plage que la fonction d'assistance utiliserait pour parcourir le parcours de l'arbre. Par défaut, editor.selection s'il n'est pas fourni. Nous utilisons également la valeur par défaut pour notre cas d'utilisation ci-dessous car nous nous intéressons aux nœuds dans la sélection de l'utilisateur.
  • match
    Il s'agit d'une fonction de correspondance que l'on peut fournir qui est appelée sur chaque nœud et incluse si elle un match. Nous utilisons ce paramètre dans notre implémentation ci-dessous pour filtrer uniquement pour bloquer les éléments.
  • mode
    Que les fonctions d'assistance sachent si nous sommes intéressés par tous les nœuds de niveau le plus élevé ou le plus bas à la fonction de correspondance de localisation donnée match . Ce paramètre (réglé sur le plus élevé ) nous aide à nous échapper en essayant de traverser l'arbre jusqu'à nous-mêmes pour trouver les nœuds de niveau supérieur.
  • universal
    Flag to choose entre des correspondances complètes ou partielles des nœuds. ( GitHub Issue avec la proposition pour cet indicateur a quelques exemples l'expliquant)
  • reverse
    Si la recherche de nœuds doit être dans le sens inverse des points de début et de fin de l'emplacement passé.
  • voids
    Si la recherche doit filtrer uniquement sur les éléments vides.

SlateJS expose de nombreuses fonctions d'assistance qui vous permettent de rechercher des nœuds de différentes manières, de parcourir l'arborescence, de mettre à jour les nœuds ou des sélections de manière complexe. Il vaut la peine de se pencher sur certaines de ces interfaces (listées vers la fin de cet article) lors de la construction de fonctionnalités d'édition complexes sur Slate.

Avec cet arrière-plan sur la fonction d'assistance, vous trouverez ci-dessous une implémentation de getTextBlockStyle .

 # src / utils / EditorUtils.js

fonction d'exportation getTextBlockStyle (éditeur) {
  const selection = editor.selection;
  if (sélection == null) {
    return null;
  }

  const topLevelBlockNodesInSelection = Editor.nodes (éditeur, {
    à: editor.selection,
    mode: "le plus élevé",
    match: (n) => Editor.isBlock (éditeur, n),
  });

  laissez blockType = null;
  laissez nodeEntry = topLevelBlockNodesInSelection.next ();
  while (! nodeEntry.done) {
    const [node, _] = nodeEntry.value;
    if (blockType == null) {
      blockType = node.type;
    } else if (blockType! == node.type) {
      retourne "multiple";
    }

    nodeEntry = topLevelBlockNodesInSelection.next ();
  }

  return blockType;
}
Examen des performances

L'implémentation actuelle de Editor.nodes trouve tous les nœuds de l'arborescence à tous les niveaux qui sont dans la plage du paramètre à puis exécute la correspondance filtres dessus (vérifiez nodeEntries et le filtrage plus tard – source ). Cela convient aux petits documents. Cependant, pour notre cas d'utilisation, si l'utilisateur a sélectionné, disons 3 titres et 2 paragraphes (chaque paragraphe contenant disons 10 nœuds de texte), il parcourra au moins 25 nœuds (3 + 2 + 2 * 10) et essaiera d'exécuter des filtres sur eux. Since we already know we’re interested in top-level nodes only, we could find start and end indexes of the top level blocks from the selection and iterate ourselves. Such a logic would loop through only 3 node entries (2 headings and 1 paragraph). Code for that would look something like below:

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 

As we add more functionalities to a WYSIWYG Editor and need to traverse the document tree often, it is important to think about the most performant ways to do so for the use case at hand as the available API or helper methods might not always be the most efficient way to do so.

Once we have getTextBlockStyle implemented, toggling of the block style is relatively straightforward. If the current style is not what user selected in the dropdown, we toggle the style to that. If it is already what user selected, we toggle it to be a paragraph. Because we are representing paragraph styles as nodes in our document structure, toggle a paragraph style essentially means changing the type property on the node. We use Transforms.setNodes provided by Slate to update properties on nodes.

Our toggleBlockType’s implementation is as below:

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

Finally, we update our Paragraph-Style dropdown to use these utility functions.

#src/components/Toolbar.js

const onBlockTypeChange = useCallback(
    (targetType) => {
      if (targetType === "multiple") {
        return;
      }
      toggleBlockType(editor, targetType);
    },
    [editor]
  );

  const blockType = getTextBlockStyle(editor);

return (
    
{PARAGRAPH_STYLES.map((blockType) => ( {getLabelForBlockStyle(blockType)} ))} .... );
Selecting multiple block types and changing the type with the dropdown.

In this section, we are going to add support to show, add, remove and change links. We will also add a Link-Detector functionality — quite similar to how Google Docs or MS Word that scan the text typed by the user and checks if there are links in there. If there are, they are converted into link objects so that the user doesn’t have to use toolbar buttons to do that themselves.

In our editor, we are going to implement links as inline nodes with SlateJS. We update our editor config to flag links as inline nodes for SlateJS and also provide a component to render so Slate knows how to render the link nodes.

# 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 ;
      ...
  }
}
# src/components/Link.js
export default function Link({ element, attributes, children }) {
  return (
    
      {children}
    
  );
}

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)

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 (
    
... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={} />
);
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. (Large preview)

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
       toggleLinkAtSelection(editor)}
      />

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 (
    
      
    
  );
}
# src/components/Editor.js

{isLinkNodeAtSelection(editor, selection) ? : null}

Since we are rendering the LinkEditor outside the editor, we need a way to tell LinkEditor where the link is located in the DOM tree so it could render itself near the editor. The way we do this is use Slate’s React API to find the DOM node corresponding to the link node in selection. And we then use getBoundingClientRect() to find the link’s DOM element’s bounds and the editor component’s bounds and compute the top and left for the link editor. The code updates to Editor and LinkEditor are as below —

# src/components/Editor.js 

const editorRef = useRef(null)
{isLinkNodeAtSelection(editor, selection) ? ( ) : 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 ;
}

SlateJS internally maintains maps of nodes to their respective DOM elements. We access that map and find the link’s DOM element using ReactEditor.toDOMNode.

Selection inside a link shows the link editor popover.

As seen in the video above, when a link is inserted and doesn’t have a URL, because the selection is inside the link, it opens the link editor thereby giving the user a way to type in a URL for the newly inserted link and hence closes the loop on the user experience there.

We now add an input element and a button to the LinkEditor that let the user type in a URL and apply it to the link node. We use the isUrl package for URL validation.

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

With the form elements wired up, let’s see if the link editor works as expected.

Editor losing selection on clicking inside link editor

As we see here in the video, when the user tries to click into the input, the link editor disappears. This is because as we render the link editor outside the Editable component, when the user clicks on the input element, SlateJS thinks the editor has lost focus and resets the selection to be null which removes the LinkEditor since isLinkActiveAtSelection is not true anymore. There is an open GitHub Issue that talks about this Slate behavior. One way to solve this is to track the previous selection of a user as it changes and when the editor does lose focus, we could look at the previous selection and still show a link editor menu if previous selection had a link in it. Let’s update the useSelection hook to remember the previous selection and return that to the Editor component.


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

We then update the logic in the Editor component to show the link menu even if the previous selection had a link in it.

# 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 (
    ...
            
{selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );

We then update LinkEditor to use selectionForLink to look up the link node, render below it and update it’s URL.

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

Most of the word processing applications identify and convert links inside text to link objects. Let’s see how that would work in the editor before we start building it.

Links being detected as the user types them in.

The steps of the logic to enable this behavior would be:

  1. As the document changes with the user typing, find the last character inserted by the user. If that character is a space, we know there must be a word that might have come before it.
  2. If the last character was space, we mark that as the end boundary of the word that came before it. We then traverse back character by character inside the text node to find where that word began. During this traversal, we have to be careful to not go past the edge of the start of the node into the previous node.
  3. Once we have found the start and end boundaries of the word before, we check the string of the word and see if that was a URL. If it was, we convert it into a link node.

Our logic lives in a util function identifyLinksInTextIfAny that lives in EditorUtils and is called inside the onChange in Editor component.

# src/components/Editor.js

  const onChangeHandler = useCallback(
    (document) => {
      ...
      identifyLinksInTextIfAny(editor);
    },
    [editor, onChange, setSelection]
  );

Here is identifyLinksInTextIfAny with the logic for Step 1 implemented:

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

There are two SlateJS helper functions which make things easy here.

  • Editor.before — Gives us the point before a certain location. It takes unit as a parameter so we could ask for the character/word/block etc before the location passed in.
  • Editor.string — Gets the string inside a range.

As an example, the diagram below explains what values of these variables are when the user inserts a character ‘E’ and their cursor is sitting after it.

Diagram explaining where cursorPoint and startPointOfLastCharacter point to after step 1 with an example
cursorPoint and startPointOfLastCharacter after Step 1 with an example text. (Large preview)

If the text ’ABCDE’ was the first text node of the first paragraph in the document, our point values would be —

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

If the last character was a space, we know where it started — startPointOfLastCharacter.Let’s move to step-2 where we move backwards character-by-character until either we find another space or the start of the text node itself.

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

Here is a diagram that shows where these different points point to once we find the last word entered to be ABCDE.

Diagram explaining where different points are after step 2 of link detection with an example
Where different points are after step 2 of link detection with an example. (Large preview)

Note that start and end are the points before and after the space there. Similarly, startPointOfLastCharacter and cursorPoint are the points before and after the space user just inserted. Hence [end,startPointOfLastCharacter] gives us the last word inserted.

We log the value of lastWord to the console and verify the values as we type.

Console logs verifying last word as entered by the user after the logic in Step 2.

Now that we have deduced what the last word was that the user typed, we verify that it was a URL indeed and convert that range into a link object. This conversion looks similar to how the toolbar link button converted a user’s selected text into a link.

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

identifyLinksInTextIfAny is called inside Slate’s onChange so we wouldn’t want to update the document structure inside the onChange. Hence, we put this update on our task queue with a Promise.resolve().then(..) call.

Let’s see the logic come together in action! We verify if we insert links at the end, in the middle or the start of a text node.

Links being detected as user is typing them.

With that, we have wrapped up functionalities for links on the editor and move on to Images.

Handling Images

In this section, we focus on adding support to render image nodes, add new images and update image captions. Images, in our document structure, would be represented as Void nodes. Void nodes in SlateJS (analogous to Void elements in HTML spec) are such that their contents are not editable text. That allows us to render images as voids. Because of Slate’s flexibility with rendering, we can still render our own editable elements inside Void elements — which we will for image caption-editing. SlateJS has an example which demonstrates how you can embed an entire Rich Text Editor inside a Void element.

To render images, we configure the editor to treat images as Void elements and provide a render implementation of how images should be rendered. We add an image to our ExampleDocument and verify that it renders correctly with the caption.

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



``
# src/components/Image.js
function Image({ attributes, children, element }) {
  return (
    
{element.caption}
{element.caption}
{children}
); }

Two things to remember when trying to render void nodes with SlateJS:

  • The root DOM element should have contentEditable={false} set on it so that SlateJS treats its contents so. Without this, as you interact with the void element, SlateJS may try to compute selections etc. and break as a result.
  • Even if Void nodes don’t have any child nodes (like our image node as an example), we still need to render children and provide an empty text node as child (see ExampleDocument below) which is treated as a selection point of the Void element by SlateJS

We now update the ExampleDocument to add an image and verify that it shows up with the caption in the 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: "" }],
  },
];
Image rendered in the Editor
Image rendered in the Editor. (Large preview)

Now let’s focus on caption-editing. The way we want this to be a seamless experience for the user is that when they click on the caption, we show a text input where they can edit the caption. If they click outside the input or hit the RETURN key, we treat that as a confirmation to apply the caption. We then update the caption on the image node and switch the caption back to read mode. Let’s see it in action so we have an idea of what we’re building.

Image Caption Editing in action.

Let’s update our Image component to have a state for caption’s read-edit modes. We update the local caption state as the user updates it and when they click out (onBlur) or hit RETURN (onKeyDown), we apply the caption to the node and switch to read mode again.

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 ? (
          
        ) : (
          
{caption}
)}
...

With that, the caption editing functionality is complete. We now move to adding a way for users to upload images to the editor. Let’s add a toolbar button that lets users select and upload an image.

# src/components/Toolbar.js

const onImageSelected = useImageUploadHandler(editor, previousSelection);

return (
    
.... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> } />

As we work with image uploads, the code could grow quite a bit so we move the image-upload handling to a hook useImageUploadHandler that gives out a callback attached to the file-input element. We’ll discuss shortly about why it needs the previousSelection state.

Before we implement useImageUploadHandlerwe’ll set up the server to be able to upload an image to. We setup an Express server and install two other packages — cors and multer that handle file uploads for us.

yarn add express cors multer

We then add a src/server.js script that configures the Express server with cors and multer and exposes an endpoint /upload which we will upload the image to.

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

Now that we have the server setup, we can focus on handling the image upload. When the user uploads an image, it could be a few seconds before the image gets uploaded and we have a URL for it. However, we do what to give the user immediate feedback that the image upload is in progress so that they know the image is being inserted in the editor. Here are the steps we implement to make this behavior work –

  1. Once the user selects an image, we insert an image node at the user’s cursor position with a flag isUploading set on it so we can show the user a loading state.
  2. We send the request to the server to upload the image.
  3. Once the request is complete and we have an image URL, we set that on the image and remove the loading state.

Let’s begin with the first step where we insert the image node. Now, the tricky part here is we run into the same issue with selection as with the link button in the toolbar. As soon as the user clicks on the Image button in the toolbar, the editor loses focus and the selection becomes null. If we try to insert an image, we don’t know where the user’s cursor was. Tracking previousSelection gives us that location and we use that to insert the node.

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

As we insert the new image node, we also assign it an identifier id using the uuid package. We’ll discuss in Step (3)’s implementation why we need that. We now update the image component to use the isUploading flag to show a loading state.

{!element.isUploading && element.url != null ? (
   {caption}
) : (
   
)}

That completes the implementation of step 1. Let’s verify that we are able to select an image to upload, see the image node getting inserted with a loading indicator where it was inserted in the document.

Image upload creating an image node with loading state.

Moving to Step (2), we will use axois library to send a request to the 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
      });
  }, [...]);
}

We verify that the image upload works and the image does show up in the public/photos folder of the app. Now that the image upload is complete, we move to Step (3) where we want to set the URL on the image in the resolve() function of the axios promise. We could update the image with Transforms.setNodes but we have a problem — we do not have the path to the newly inserted image node. Let’s see what our options are to get to that image —

  • Can’t we use editor.selection as the selection must be on the newly inserted image node? We cannot guarantee this since while the image was uploading, the user might have clicked somewhere else and the selection might have changed.
  • How about using previousSelection which we used to insert the image node in the first place? For the same reason we can’t use editor.selectionwe can’t use previousSelection since it may have changed too.
  • SlateJS has a History module that tracks all the changes happening to the document. We could use this module to search the history and find the last inserted image node. This also isn’t completely reliable if it took longer for the image to upload and the user inserted more images in different parts of the document before the first upload completed.
  • Currently, Transform.insertNodes’s API doesn’t return any information about the inserted nodes. If it could return the paths to the inserted nodes, we could use that to find the precise image node we should update.

Since none of the above approaches work, we apply an id to the inserted image node (in Step (1)) and use the same id again to locate it when the image upload is complete. With that, our code for Step (3) looks like below —

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

With the implementation of all three steps complete, we are ready to test the image upload end to end.

Image upload working end-to-end

With that, we’ve wrapped up Images for our editor. Currently, we show a loading state of the same size irrespective of the image. This could be a jarring experience for the user if the loading state is replaced by a drastically smaller or bigger image when the upload completes. A good follow up to the upload experience is getting the image dimensions before the upload and showing a placeholder of that size so that transition is seamless. The hook we add above could be extended to support other media types like video or documents and render those types of nodes as well.

Conclusion

In this article, we have built a WYSIWYG Editor that has a basic set of functionalities and some micro user-experiences like link detection, in-place link editing and image caption editing that helped us go deeper with SlateJS and concepts of Rich Text Editing in general. If this problem space surrounding Rich Text Editing or Word Processing interests you, some of the cool problems to go after could be:

  • Collaboration
  • A richer text editing experience that supports text alignments, inline images, copy-paste, changing font and text colors etc.
  • Importing from popular formats like Word documents and Markdown.

If you want to learn more SlateJS, here are some links that might be helpful.

  • SlateJS Examples
    A lot of examples that go beyond the basics and build functionalities that are usually found in Editors like Search & Highlight, Markdown Preview and Mentions.
  • API Docs
    Reference to a lot of helper functions exposed by SlateJS that one might want to keep handy when trying to perform complex queries/transformations on SlateJS objects.

Lastly, SlateJS’s Slack Channel is a very active community of web developers building Rich Text Editing applications using SlateJS and a great place to learn more about the library and get help if needed.

Smashing Editorial" width="35" height="46" loading="lazy" decoding="async(vf, il)




Source link

Revenir vers le haut