Construire un éditeur de texte enrichi (WYSIWYG)
Publié: 2022-03-10Ces dernières années, le domaine de la création et de la représentation de contenu sur les plateformes numériques a connu une perturbation massive. Le succès généralisé de produits comme Quip, Google Docs et Dropbox Paper a montré comment les entreprises s'efforcent de créer la meilleure expérience pour les créateurs de contenu dans le domaine de l'entreprise et essaient de trouver des moyens innovants de briser les moules traditionnels de partage et de consommation du contenu. Profitant de la portée massive des plateformes de médias sociaux, il existe une nouvelle vague de créateurs de contenu indépendants utilisant des plateformes 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 offrent 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 également 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 excellente expérience utilisateur pour un créateur de contenu.
Comprendre la structure du document
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ées.
Nœuds de document
Les nœuds de document sont utilisés pour représenter le contenu du document. Les types de nœuds courants qu'un document en texte enrichi peut contenir sont les paragraphes, les en-têtes, 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 afficher ces nœuds dans l'éditeur. (par exemple, les nœuds d'image contiennent une propriété src
d'image, les blocs de code peuvent contenir une propriété de language
, etc.).
Il existe en grande partie deux types de nœuds qui représentent la façon dont ils doivent être rendus -
- 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.
- Nœuds en ligne (analogues au concept HTML des éléments en ligne) qui commencent le rendu sur la même ligne que le nœud précédent. Il existe certaines différences dans la manière 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 populaire d'édition de texte enrichi, vous permet d'utiliser le concept d'entités pour afficher des éléments en ligne. Les liens et les images en ligne sont des exemples de nœuds en ligne.
- Nœuds vides — SlateJS permet également cette troisième catégorie de nœuds que nous utiliserons plus tard dans cet article pour rendre les médias.
Si vous souhaitez en savoir plus sur ces catégories, la documentation de SlateJS sur les nœuds est un bon point de départ.
Les attributs
Semblable au concept d'attributs HTML, les attributs d'un document RTF 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 ( paragraph
& h1-h6
) comme attributs.
L'image ci-dessous 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.
Certaines des choses qui méritent d'être rappelé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 etcaption
pour les images) - La représentation spécifique à SlateJS des attributs de texte divise les nœuds de texte en leurs 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
défini dessus. Il en va de même pour le texte en italique, souligné et en style code dans ce document.
Emplacements et sélection
Lors de la construction d'un éditeur de texte enrichi, il est crucial de comprendre comment la partie la plus granulaire d'un document (disons un caractère) peut être représentée 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 des utilisateurs qui est assez largement utilisée pour personnaliser l'expérience utilisateur de l'éditeur en temps réel. Nous utiliserons la sélection pour construire notre barre d'outils plus loin dans cet article. Des exemples de ceux-ci pourraient être:
- 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 appuie sur le bouton SUPPRIMER, nous déterminons quel était le texte sélectionné par l'utilisateur et le supprimons du document.
Le document de SlateJS sur l'emplacement explique en détail ces structures de données, mais nous les parcourons ici rapidement car nous utilisons ces termes à différents moments 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 3e nœud enfant du 2e nœud dans le document. - Indiquer
Emplacement plus granulaire du contenu représenté par chemin + décalage. Par exemple, un point de{path: [2,3], offset: 14}
représente le 14e caractère du 3e nœud enfant à l'intérieur du 2e nœud du document. - Varier
Une paire de points (appelésanchor
etfocus
) qui représentent une plage de texte à l'intérieur du document. Ce concept vient de l'API de sélection Web où l'anchor
est l'endroit où la sélection de l'utilisateur a commencé et lefocus
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).
A titre d'exemple, disons que la sélection de l'utilisateur dans notre exemple de document ci-dessus est ipsum
:
La sélection de l'utilisateur peut être représentée comme suit :
{ 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' }`
Configuration de l'éditeur
Dans cette section, nous allons configurer l'application et obtenir un éditeur de texte enrichi de base avec SlateJS. L'application passe-partout serait
avec des dépendances SlateJS ajoutées. Nous construisons l'interface utilisateur de l'application en utilisant des composants de create-react-app
. Commençons!react-bootstrap
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 de yarn start
qui devrait faire tourner le serveur Web local (le port par défaut est 3000) et vous montrer un écran de bienvenue React.
npx create-react-app . yarn start
Nous passons ensuite à l'ajout des dépendances SlateJS à l'application.
yarn add slate slate-react
slate
est le package principal de SlateJS et slate-react
inclut 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 à ci-dessous :
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
Nous ajoutons maintenant un dossier appelé components
qui contiendra tous nos composants React et procédons comme suit :
- Ajoutez-y notre premier composant React
Editor.js
. Il ne renvoie qu'undiv
pour l'instant. - Mettez à jour le composant
App.js
pour conserver le document dans son état qui est initialisé à notreExampleDocument
ci-dessus. - Rendez l'éditeur dans l'application et transmettez 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 ajouter é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); 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> </> );
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.
// dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);
createEditor
nous donne l'instance de l' editor
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. Les plugins SlateJS sont des fonctions Javascript qui reçoivent l'objet editor
et y attachent une configuration. Cela permet aux développeurs Web d'ajouter des configurations à leur instance d'éditeur SlateJS de manière composable.
Nous importons et rendons maintenant les composants <Slate />
et <Editable />
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 au code de l'application. Editable
est le composant qui rend la hiérarchie du document pour l'édition. Dans l'ensemble, le module
à ce stade ressemble à ceci :Editor.js
import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }
À ce stade, nous avons ajouté les composants React nécessaires et l'éditeur est rempli avec un exemple de document. Notre éditeur devrait maintenant être configuré pour nous permettre de saisir et de modifier le contenu en temps réel - comme dans le screencast ci-dessous.
Passons maintenant à la section suivante où nous configurons l'é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 pouvoir le faire, nous fournissons une prop de fonction renderElement
aux composants de Slate. Cette fonction est appelée par Slate au moment de l'exécution lorsqu'elle essaie de parcourir l'arborescence du document et de restituer chaque nœud. La fonction renderElement obtient trois paramètres —
-
attributes
Spécifique à SlateJS qui doit être appliqué à l'élément DOM de niveau supérieur renvoyé par cette fonction. -
element
L'objet nœud 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 crochet 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 } 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} />; } }
Étant donné que cette fonction nous donne accès à l' element
(qui est le nœud lui-même), nous pouvons personnaliser renderElement
pour implémenter un rendu plus personnalisé qui fait plus que simplement 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); return ( ... <Editable renderElement={renderElement} /> );
Avec le rendu personnalisé en place, nous mettons à jour le ExampleDocument pour inclure nos nouveaux types de nœuds et vérifions qu'ils s'affichent correctement dans l'éditeur.
const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes
Styles de caractères
Semblable à renderElement
, SlateJS fournit une fonction prop appelée renderLeaf qui peut être utilisée pour personnaliser le rendu des nœuds de texte ( Leaf
faisant référence aux nœuds de texte qui sont les feuilles/nœuds de niveau le plus bas de l'arborescence du document). En suivant l'exemple de renderElement
, nous écrivons une implémentation pour 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>; }
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. Étant donné que renderLeaf nous donne accès à la leaf
nœud de texte elle-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é leaf 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érifions 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(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 }, ], },
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ère et une liste déroulante pour les styles de paragraphe et nous les connectons plus tard dans la section.
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> ); }
Nous réduisons les boutons au composant ToolbarButton
qui est un wrapper autour du composant React Bootstrap Button. Nous rendons ensuite la barre d'outils au-dessus du composant Editable
inside Editor
et vérifions que la barre d'outils s'affiche dans l'application.
Voici les trois fonctionnalités clés que la barre d'outils doit prendre en charge :
- Lorsque le curseur de l'utilisateur se trouve à un certain endroit du document et qu'il clique sur l'un des boutons de style de caractère, nous devons basculer le style du texte qu'il peut ensuite saisir.
- Lorsque l'utilisateur sélectionne une plage de texte et clique sur l'un des boutons de style de caractère, nous devons basculer le style pour cette section spécifique.
- 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.
Écouter 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 de la rédaction de cet article, SlateJS n'expose pas de méthode onSelectionChange
qui pourrait nous donner le dernier état de sélection du document. Cependant, lorsque 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 comme un moyen d'être informé du changement de sélection et de le stocker dans l'état du composant Editor
. Nous réduisons cela à un crochet 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"; 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]; }
Nous utilisons ce crochet dans le composant Editor
comme ci-dessous et transmettons la sélection au composant 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} /> ...
Considération des performances
Dans une application où nous avons une base de code Editor 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 en utilisant une bibliothèque de gestion d'état) car les composants écoutant les changements de sélection sont susceptibles de rendre aussi souvent. Une façon d'y parvenir est d'avoir des sélecteurs optimisés au-dessus de l'état Sélection qui contiennent des informations de sélection spécifiques. Par exemple, un éditeur peut souhaiter afficher 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 serait restitué que lorsque la valeur de ce sélecteur change. Redux's Reselect est l'une de ces bibliothèques qui permet de créer des sélecteurs.
Nous n'utilisons la selection
dans la barre d'outils que plus tard, mais la transmettre en tant qu'accessoire rend la barre d'outils restituée à chaque fois que la sélection change dans l'éditeur. Nous faisons cela parce que nous ne pouvons pas compter uniquement sur le changement de 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 modifiant jamais réellement le contenu du document lui-même.
Basculer les styles de caractère
Nous passons maintenant à l'obtention des styles de caractères actifs de SlateJS et à leur utilisation dans l'éditeur. Ajoutons un nouveau module JS EditorUtils
qui hébergera toutes les fonctions utilitaires 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 de l'éditeur — 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); } }
Les deux fonctions prennent l'objet de l' editor
qui est l'instance Slate comme paramètre, tout comme de nombreuses fonctions utilitaires que nous ajouterons plus tard dans l'article. Dans la terminologie Slate, les styles de formatage sont appelés marques et nous utilisons des méthodes d'assistance sur l'interface de l'éditeur pour obtenir, ajouter et supprimez ces marques. Nous importons ces fonctions utilitaires 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 } 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
est un crochet Slate qui nous donne accès à l'instance Slate à partir du contexte où elle a été attachée par le composant <Slate>
plus haut dans la hiérarchie de rendu.
On peut se demander pourquoi on utilise ici onMouseDown
au lieu de onClick
? Il existe un problème Github ouvert sur la façon dont Slate transforme la selection
en null
lorsque l'éditeur perd le focus de quelque manière que ce soit. Ainsi, si nous attachons des gestionnaires onClick
à nos boutons de barre d'outils, la selection
devient null
et les utilisateurs perdent la position de leur curseur en essayant de changer de style, ce qui n'est pas une expérience formidable. À la place, nous basculons le style en attachant un événement onMouseDown
qui empêche la sélection d'être réinitialisée. Une autre façon de le 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 sélection 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 l'utilisons pour connecter des raccourcis clavier pour basculer les styles de caractères. Pour ce faire, nous ajoutons un objet KeyBindings
dans 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 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} />
Faire fonctionner la liste déroulante des styles de paragraphe
Passons maintenant au fonctionnement de la liste déroulante Styles de paragraphe. Semblable au fonctionnement des listes déroulantes de style paragraphe dans les applications de traitement de texte populaires telles que MS Word ou Google Docs, nous souhaitons que les styles des blocs de niveau supérieur 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 qu'elle soit celle-là. S'il y en a plusieurs, nous définissons la valeur de la liste déroulante 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 Editor.nodes
de Slate - Une fonction d'assistance couramment utilisée pour rechercher des nœuds dans une arborescence filtrée par différentes options.
nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>
La fonction d'assistance prend une instance de l'éditeur et un objet d' options
qui est un moyen de filtrer les nœuds de l'arborescence lorsqu'il 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 le chemin vers celui-ci — [node, pathToNode]
. Les options trouvées ici sont disponibles sur la plupart des fonctions d'assistance Slate. Passons en revue ce que chacun de ces signifie:
-
at
Il peut s'agir d'un chemin/point/plage que la fonction d'assistance utiliserait pour délimiter la traversée de l'arborescence. Par défauteditor.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 s'il s'agit d'une correspondance. Nous utilisons ce paramètre dans notre implémentation ci-dessous pour filtrer uniquement les éléments de blocage. -
mode
Faisons savoir aux fonctions d'assistance si nous sommes intéressés par tous les nœuds, de niveau le plus élevé ou de niveau le plus bas,at
la fonction dematch
d'emplacement donnée. Ce paramètre (défini sur lehighest
) nous aide à éviter d'essayer de parcourir l'arborescence nous -mêmes pour trouver les nœuds de niveau supérieur. -
universal
Indicateur permettant de choisir entre des correspondances complètes ou partielles des nœuds. (GitHub Issue avec la proposition de ce drapeau a quelques exemples l'expliquant) -
reverse
Si la recherche de nœud doit être dans le sens inverse des points de début et de fin de l'emplacement transmis. -
voids
Si la recherche doit filtrer uniquement les éléments vides.
SlateJS expose de nombreuses fonctions d'assistance qui vous permettent d'interroger les nœuds de différentes manières, de parcourir l'arborescence, de mettre à jour les nœuds ou les sélections de manière complexe. Cela vaut la peine de creuser dans certaines de ces interfaces (énumérées vers la fin de cet article) lors de la création 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 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; }
Considération des performances
L'implémentation actuelle de Editor.nodes
trouve tous les nœuds de l'arborescence à tous les niveaux qui se trouvent dans la plage du paramètre at
, puis exécute des filtres de correspondance dessus (vérifiez nodeEntries
et le filtrage plus tard - source). C'est correct pour les 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. Puisque nous savons déjà que nous nous intéressons uniquement aux nœuds de niveau supérieur, nous pourrions trouver les index de début et de fin des blocs de niveau supérieur à partir de la sélection et nous itérer. Une telle logique bouclerait sur seulement 3 entrées de nœud (2 titres et 1 paragraphe). Le code pour cela ressemblerait à quelque chose comme ci-dessous :
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; }
Au fur et à mesure que nous ajoutons plus de fonctionnalités à un éditeur WYSIWYG et que nous devons souvent parcourir l'arborescence du document, il est important de réfléchir aux moyens les plus performants de le faire pour le cas d'utilisation en question, car l'API ou les méthodes d'assistance disponibles ne sont pas toujours les plus efficaces. moyen efficace de le faire.
Une fois que nous avons implémenté getTextBlockStyle
, le basculement du style de bloc est relativement simple. Si le style actuel n'est pas celui que l'utilisateur a sélectionné dans la liste déroulante, nous basculons le style vers celui-ci. Si c'est déjà ce que l'utilisateur a sélectionné, nous le transformons en paragraphe. Étant donné que nous représentons les styles de paragraphe sous forme de nœuds dans la structure de notre document, basculer un style de paragraphe signifie essentiellement modifier la propriété de type
sur le nœud. Nous utilisons Transforms.setNodes
fourni par Slate pour mettre à jour les propriétés des nœuds.
L'implémentation de notre toggleBlockType
est la suivante :
# 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) } ); }
Enfin, nous mettons à jour notre liste déroulante Style de paragraphe pour utiliser ces fonctions utilitaires.
#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> .... );
LIENS
Dans cette section, nous allons ajouter un support pour afficher, ajouter, supprimer et modifier des liens. Nous ajouterons également une fonctionnalité Link-Detector - assez similaire à la façon dont Google Docs ou MS Word analyse le texte saisi par l'utilisateur et vérifie s'il contient des liens. S'il y en a, ils sont convertis en objets lien afin que l'utilisateur n'ait pas à utiliser les boutons de la barre d'outils pour le faire lui-même.
Liens de rendu
Dans notre éditeur, nous allons implémenter des liens sous forme de nœuds en ligne avec SlateJS. Nous mettons à jour la configuration de notre éditeur pour signaler les liens en tant que nœuds en ligne pour SlateJS et fournissons également un composant à rendre afin que Slate sache comment rendre les nœuds de lien.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
# src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }
We then add a link node to our ExampleDocument
and verify that it renders correctly (including a case for character styles inside a link) in the Editor.
# src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... }
Adding A Link Button To The Toolbar
Let's add a Link Button to the toolbar that enables the user to do the following:
- Selecting some text and clicking on the button converts that text into a link
- Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
- If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.
To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above
helper function from SlateJS.
# src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }
Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.
# src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> );
To toggle links in the editor, we add a util function toggleLinkAtSelection
. Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:
If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes
that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes
which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection
has the below implementation to insert a new link at an expanded selection.
# src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }
If the selection is collapsed, we insert a new node there with
that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.Transform.insertNodes
# src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} />
Link Editor Menu
So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor
component and rendering it whenever the user selection is inside a link.
# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
# src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>
Puisque nous rendons le LinkEditor
en dehors de l'éditeur, nous avons besoin d'un moyen d'indiquer à LinkEditor
où se trouve le lien dans l'arborescence DOM afin qu'il puisse se rendre près de l'éditeur. Pour ce faire, nous utilisons l'API React de Slate pour trouver le nœud DOM correspondant au nœud de lien sélectionné. Et nous utilisons ensuite getBoundingClientRect()
pour trouver les limites de l'élément DOM du lien et les limites du composant éditeur et calculer le top
et la left
pour l'éditeur de lien. Les mises à jour de code pour Editor
et LinkEditor
sont comme ci-dessous —
# 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 gère en interne les cartes des nœuds vers leurs éléments DOM respectifs. Nous accédons à cette carte et trouvons l'élément DOM du lien en utilisant ReactEditor.toDOMNode
.
Comme on le voit dans la vidéo ci-dessus, lorsqu'un lien est inséré et n'a pas d'URL, parce que la sélection est à l'intérieur du lien, il ouvre l'éditeur de liens donnant ainsi à l'utilisateur un moyen de saisir une URL pour le lien nouvellement inséré et ferme donc la boucle sur l'expérience utilisateur là-bas.
Nous ajoutons maintenant un élément d'entrée et un bouton au LinkEditor
qui permettent à l'utilisateur de saisir une URL et de l'appliquer au nœud de lien. Nous utilisons le package isUrl
pour la validation d'URL.
# src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );
Avec les éléments de formulaire câblés, voyons si l'éditeur de liens fonctionne comme prévu.
Comme nous le voyons ici dans la vidéo, lorsque l'utilisateur essaie de cliquer sur l'entrée, l'éditeur de liens disparaît. En effet, lorsque nous rendons l'éditeur de liens en dehors du composant Editable
, lorsque l'utilisateur clique sur l'élément d'entrée, SlateJS pense que l'éditeur a perdu le focus et réinitialise la selection
à null
, ce qui supprime le LinkEditor
puisque isLinkActiveAtSelection
n'est plus true
. Il existe un problème GitHub ouvert qui parle de ce comportement Slate. Une façon de résoudre ce problème consiste à suivre la sélection précédente d'un utilisateur au fur et à mesure qu'elle change et lorsque l'éditeur perd le focus, nous pourrions regarder la sélection précédente et toujours afficher un menu d'éditeur de liens si la sélection précédente contenait un lien. Mettons à jour le hook useSelection
pour mémoriser la sélection précédente et la renvoyer au composant 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]; }
Nous mettons ensuite à jour la logique dans le composant Editor
pour afficher le menu de liens même si la sélection précédente contenait un lien.
# 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={..} ... );
Nous mettons ensuite à jour LinkEditor
pour utiliser selectionForLink
pour rechercher le nœud de lien, le rendre en dessous et mettre à jour son URL.
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
Détection des liens dans le texte
La plupart des applications de traitement de texte identifient et convertissent les liens à l'intérieur du texte pour lier des objets. Voyons comment cela fonctionnerait dans l'éditeur avant de commencer à le construire.
Les étapes de la logique pour activer ce comportement seraient :
- Au fur et à mesure que le document change avec la saisie de l'utilisateur, recherchez le dernier caractère inséré par l'utilisateur. Si ce caractère est un espace, nous savons qu'il doit y avoir un mot qui pourrait l'avoir précédé.
- Si le dernier caractère était un espace, nous le marquons comme la limite de fin du mot qui le précède. Nous revenons ensuite caractère par caractère à l'intérieur du nœud de texte pour trouver où ce mot a commencé. Au cours de cette traversée, nous devons faire attention à ne pas dépasser le bord du début du nœud dans le nœud précédent.
- Une fois que nous avons trouvé les limites de début et de fin du mot précédent, nous vérifions la chaîne du mot et voyons s'il s'agissait d'une URL. Si c'était le cas, nous le convertissons en nœud de lien.
Notre logique réside dans une fonction util identifyLinksInTextIfAny
qui réside dans EditorUtils
et est appelée dans le composant onChange
in Editor
.
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
Voici identifyLinksInTextIfAny
avec la logique de l'étape 1 implémentée :
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; }
Il existe deux fonctions d'assistance SlateJS qui facilitent les choses ici.
-
Editor.before
— nous donne le point avant un certain emplacement. Il prendunit
comme paramètre afin que nous puissions demander le caractère/mot/bloc, etc. avant que l'location
soit transmis. -
Editor.string
— Récupère la chaîne à l'intérieur d'une plage.
À titre d'exemple, le diagramme ci-dessous explique quelles sont les valeurs de ces variables lorsque l'utilisateur insère un caractère "E" et que son curseur se trouve après.
Si le texte « ABCDE » était le premier nœud de texte du premier paragraphe du document, nos valeurs en points seraient —
cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}
Si le dernier caractère était un espace, nous savons où il a commencé — startPointOfLastCharacter.
Passons à l'étape 2 où nous reculons caractère par caractère jusqu'à ce que nous trouvions un autre espace ou le début du nœud de texte lui-même.
... 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);
Voici un diagramme qui montre où ces différents points pointent une fois que nous avons trouvé que le dernier mot entré était ABCDE
.
Notez que le start
et la end
sont les points avant et après l'espace. De même, startPointOfLastCharacter
et cursorPoint
sont les points avant et après l'espace que l'utilisateur vient d'insérer. Ainsi [end,startPointOfLastCharacter]
nous donne le dernier mot inséré.
Nous enregistrons la valeur de lastWord
dans la console et vérifions les valeurs au fur et à mesure que nous tapons.
Maintenant que nous avons déduit quel était le dernier mot que l'utilisateur a tapé, nous vérifions qu'il s'agissait bien d'une URL et convertissons cette plage en un objet lien. Cette conversion ressemble à la façon dont le bouton de lien de la barre d'outils a converti le texte sélectionné d'un utilisateur en lien.
if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }
identifyLinksInTextIfAny
est appelé à l'intérieur de onChange
de Slate, nous ne voudrions donc pas mettre à jour la structure du document à l'intérieur de onChange
. Par conséquent, nous plaçons cette mise à jour dans notre file d'attente de tâches avec un Promise.resolve().then(..)
.
Voyons la logique se mettre en action ! Nous vérifions si nous insérons des liens à la fin, au milieu ou au début d'un nœud de texte.
Avec cela, nous avons terminé les fonctionnalités pour les liens sur l'éditeur et passons aux images.
Manipulation des images
Dans cette section, nous nous concentrons sur l'ajout de la prise en charge du rendu des nœuds d'image, l'ajout de nouvelles images et la mise à jour des légendes d'image. Les images, dans notre structure de document, seraient représentées comme des nœuds Void. Les nœuds Void dans SlateJS (analogues aux éléments Void dans les spécifications HTML) sont tels que leur contenu n'est pas un texte modifiable. Cela nous permet de rendre les images comme des vides. En raison de la flexibilité de Slate avec le rendu, nous pouvons toujours rendre nos propres éléments modifiables à l'intérieur des éléments Void - ce que nous ferons pour l'édition des légendes d'image. SlateJS a un exemple qui montre comment vous pouvez intégrer un éditeur de texte enrichi entier dans un élément Void.
Pour rendre les images, nous configurons l'éditeur pour traiter les images comme des éléments vides et fournissons une implémentation de rendu de la façon dont les images doivent être rendues. Nous ajoutons une image à notre ExampleDocument et vérifions qu'elle s'affiche correctement avec la légende.
# 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> ); }
Deux choses à retenir lorsque vous essayez de rendre des nœuds vides avec SlateJS :
- L'élément DOM racine doit avoir
contentEditable={false}
défini dessus pour que SlateJS traite son contenu ainsi. Sans cela, lorsque vous interagissez avec l'élément void, SlateJS peut essayer de calculer des sélections, etc. et s'arrêter en conséquence. - Même si les nœuds Void n'ont pas de nœuds enfants (comme notre nœud d'image par exemple), nous devons toujours rendre
children
et fournir un nœud de texte vide en tant qu'enfant (voirExampleDocument
ci-dessous) qui est traité comme un point de sélection du Void élément par SlateJS
Nous mettons maintenant à jour l' ExampleDocument
pour ajouter une image et vérifier qu'elle s'affiche avec la légende dans l'éditeur.
# 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: "" }], }, ];
Concentrons-nous maintenant sur l'édition des sous-titres. La façon dont nous voulons que ce soit une expérience transparente pour l'utilisateur est que lorsqu'il clique sur la légende, nous affichons une entrée de texte où il peut modifier la légende. S'ils cliquent en dehors de l'entrée ou appuient sur la touche RETOUR, nous traitons cela comme une confirmation pour appliquer la légende. Nous mettons ensuite à jour la légende sur le nœud d'image et rebasculons la légende en mode lecture. Voyons-le en action pour avoir une idée de ce que nous construisons.
Mettons à jour notre composant Image pour avoir un état pour les modes de lecture-édition de la légende. Nous mettons à jour l'état de la légende locale au fur et à mesure que l'utilisateur le met à jour et lorsqu'il clique ( onBlur
) ou appuie sur RETURN ( onKeyDown
), nous appliquons la légende au nœud et repassons en mode lecture.
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> ...
Avec cela, la fonctionnalité d'édition de sous-titres est terminée. Nous passons maintenant à l'ajout d'un moyen pour les utilisateurs de télécharger des images dans l'éditeur. Ajoutons un bouton de barre d'outils qui permet aux utilisateurs de sélectionner et de télécharger une image.
# 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>
Au fur et à mesure que nous travaillons avec des téléchargements d'images, le code peut grossir un peu, nous déplaçons donc la gestion du téléchargement d'images vers un hook useImageUploadHandler
qui émet un rappel attaché à l'élément file-input. Nous discuterons sous peu de la raison pour laquelle il a besoin de l'état previousSelection
.
Avant d'implémenter useImageUploadHandler
, nous allons configurer le serveur pour qu'il puisse télécharger une image. Nous configurons un serveur Express et installons deux autres packages - cors
et multer
qui gèrent les téléchargements de fichiers pour nous.
yarn add express cors multer
Nous ajoutons ensuite un script src/server.js
qui configure le serveur Express avec cors et multer et expose un point de terminaison /upload
sur lequel nous allons télécharger l'image.
# 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}`));
Maintenant que nous avons la configuration du serveur, nous pouvons nous concentrer sur la gestion du téléchargement de l'image. Lorsque l'utilisateur télécharge une image, il peut s'écouler quelques secondes avant que l'image ne soit téléchargée et que nous ayons une URL pour celle-ci. Cependant, nous faisons quoi pour informer immédiatement l'utilisateur que le téléchargement de l'image est en cours afin qu'il sache que l'image est insérée dans l'éditeur. Voici les étapes que nous mettons en œuvre pour que ce comportement fonctionne -
- Une fois que l'utilisateur a sélectionné une image, nous insérons un nœud d'image à la position du curseur de l'utilisateur avec un indicateur
isUploading
défini dessus afin que nous puissions montrer à l'utilisateur un état de chargement. - Nous envoyons la demande au serveur pour télécharger l'image.
- Une fois la demande terminée et que nous avons une URL d'image, nous la définissons sur l'image et supprimons l'état de chargement.
Commençons par la première étape où nous insérons le nœud image. Maintenant, la partie délicate ici est que nous rencontrons le même problème avec la sélection qu'avec le bouton de lien dans la barre d'outils. Dès que l'utilisateur clique sur le bouton Image de la barre d'outils, l'éditeur perd le focus et la sélection devient null
. Si nous essayons d'insérer une image, nous ne savons pas où se trouvait le curseur de l'utilisateur. Le suivi de previousSelection
nous donne cet emplacement et nous l'utilisons pour insérer le nœud.
# 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] ); }
Lorsque nous insérons le nouveau nœud d'image, nous lui attribuons également un identifiant id
à l'aide du package uuid. Nous discuterons dans l'implémentation de l'étape (3) pourquoi nous en avons besoin. Nous mettons maintenant à jour le composant image pour utiliser l'indicateur isUploading
pour afficher un état de chargement.
{!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}
Cela termine la mise en œuvre de l'étape 1. Vérifions que nous sommes en mesure de sélectionner une image à télécharger, voir le nœud d'image s'insérer avec un indicateur de chargement là où il a été inséré dans le document.
Passant à l'étape (2), nous utiliserons la bibliothèque axois pour envoyer une requête au serveur.
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 }); }, [...]); }
Nous vérifions que le téléchargement d'image fonctionne et que l'image s'affiche dans le dossier public/photos
de l'application. Maintenant que le téléchargement de l'image est terminé, nous passons à l'étape (3) où nous voulons définir l'URL sur l'image dans la fonction resolve resolve()
de la promesse axios. Nous pourrions mettre à jour l'image avec Transforms.setNodes
mais nous avons un problème - nous n'avons pas le chemin vers le nœud d'image nouvellement inséré. Voyons quelles sont nos options pour arriver à cette image —
- Ne pouvons-nous pas utiliser
editor.selection
car la sélection doit se trouver sur le nœud d'image nouvellement inséré ? Nous ne pouvons pas le garantir car pendant le téléchargement de l'image, l'utilisateur peut avoir cliqué ailleurs et la sélection peut avoir changé. - Que diriez-vous d'utiliser
previousSelection
que nous avons utilisé pour insérer le nœud d'image en premier lieu ? Pour la même raison, nous ne pouvons pas utilisereditor.selection
, nous ne pouvons pas utiliserpreviousSelection
car il peut également avoir changé. - SlateJS dispose d'un module Historique qui suit tous les changements apportés au document. Nous pourrions utiliser ce module pour rechercher l'historique et trouver le dernier nœud d'image inséré. Ce n'est pas non plus complètement fiable si le téléchargement de l'image a pris plus de temps et que l'utilisateur a inséré plus d'images dans différentes parties du document avant la fin du premier téléchargement.
- Actuellement, l'API de
Transform.insertNodes
ne renvoie aucune information sur les nœuds insérés. S'il pouvait renvoyer les chemins vers les nœuds insérés, nous pourrions l'utiliser pour trouver le nœud d'image précis que nous devrions mettre à jour.
Étant donné qu'aucune des approches ci-dessus ne fonctionne, nous appliquons un id
au nœud d'image inséré (à l'étape (1)) et utilisons à nouveau le même id
pour le localiser lorsque le téléchargement de l'image est terminé. Avec cela, notre code pour l'étape (3) ressemble à ci-dessous -
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. });
Une fois la mise en œuvre des trois étapes terminée, nous sommes prêts à tester le téléchargement d'image de bout en bout.
Avec cela, nous avons terminé Images pour notre éditeur. Actuellement, nous montrons un état de chargement de la même taille quelle que soit l'image. Cela pourrait être une expérience choquante pour l'utilisateur si l'état de chargement est remplacé par une image considérablement plus petite ou plus grande à la fin du téléchargement. Un bon suivi de l'expérience de téléchargement consiste à obtenir les dimensions de l'image avant le téléchargement et à afficher un espace réservé de cette taille afin que la transition soit transparente. Le crochet que nous ajoutons ci-dessus pourrait être étendu pour prendre en charge d'autres types de médias comme la vidéo ou les documents et restituer également ces types de nœuds.
Conclusion
Dans cet article, nous avons créé un éditeur WYSIWYG doté d'un ensemble de fonctionnalités de base et de quelques micro-expériences utilisateur telles que la détection de liens, l'édition de liens sur place et l'édition de légendes d'images qui nous ont aidés à approfondir SlateJS et les concepts d'édition de texte enrichi dans général. Si cet espace problématique entourant l'édition de texte enrichi ou le traitement de texte vous intéresse, certains des problèmes intéressants à résoudre pourraient être :
- Collaboration
- Une expérience d'édition de texte plus riche qui prend en charge les alignements de texte, les images en ligne, le copier-coller, la modification des couleurs de police et de texte, etc.
- Importation à partir de formats populaires tels que les documents Word et Markdown.
Si vous souhaitez en savoir plus sur SlateJS, voici quelques liens qui pourraient vous être utiles.
- Exemples SlateJS
De nombreux exemples qui vont au-delà des fonctionnalités de base et de construction que l'on trouve généralement dans les éditeurs tels que Search & Highlight, Markdown Preview et Mentions. - Documents d'API
Référence à de nombreuses fonctions d'assistance exposées par SlateJS que l'on peut vouloir garder à portée de main lorsqu'on essaie d'effectuer des requêtes/transformations complexes sur des objets SlateJS.
Enfin, Slack Channel de SlateJS est une communauté très active de développeurs Web qui créent des applications d'édition de texte enrichi à l'aide de SlateJS et un endroit idéal pour en savoir plus sur la bibliothèque et obtenir de l'aide si nécessaire.