Creación de un editor de texto enriquecido (WYSIWYG)
Publicado: 2022-03-10En los últimos años, el campo de la creación y representación de contenido en plataformas digitales ha experimentado una gran disrupción. El éxito generalizado de productos como Quip, Google Docs y Dropbox Paper ha demostrado cómo las empresas compiten para crear la mejor experiencia para los creadores de contenido en el ámbito empresarial y tratan de encontrar formas innovadoras de romper los moldes tradicionales de cómo se comparte y consume el contenido. Aprovechando el alcance masivo de las plataformas de redes sociales, hay una nueva ola de creadores de contenido independientes que usan plataformas como Medium para crear contenido y compartirlo con su audiencia.
Dado que muchas personas de diferentes profesiones y antecedentes intentan crear contenido en estos productos, es importante que estos productos brinden una experiencia fluida y eficaz de creación de contenido y cuenten con equipos de diseñadores e ingenieros que desarrollen cierto nivel de experiencia en el dominio con el tiempo en este espacio. . Con este artículo, intentamos no solo sentar las bases para crear un editor, sino también dar a los lectores una idea de cómo las pequeñas piezas de funcionalidades, cuando se combinan, pueden crear una gran experiencia de usuario para un creador de contenido.
Comprender la estructura del documento
Antes de sumergirnos en la creación del editor, veamos cómo se estructura un documento para un editor de texto enriquecido y cuáles son los diferentes tipos de estructuras de datos involucradas.
Nodos de documento
Los nodos de documento se utilizan para representar el contenido del documento. Los tipos comunes de nodos que podría contener un documento de texto enriquecido son párrafos, encabezados, imágenes, videos, bloques de código y comillas. Algunos de estos pueden contener otros nodos como elementos secundarios dentro de ellos (por ejemplo, los nodos de párrafo contienen nodos de texto dentro de ellos). Los nodos también tienen propiedades específicas del objeto que representan que se necesitan para representar esos nodos dentro del editor. (por ejemplo, los nodos de imagen contienen una propiedad src
de imagen, los bloques de código pueden contener una propiedad de language
, etc.).
En gran medida, hay dos tipos de nodos que representan cómo deben representarse:
- Nodos de bloque (análogo al concepto HTML de elementos de nivel de bloque) que se representan en una nueva línea y ocupan el ancho disponible. Los nodos de bloque pueden contener otros nodos de bloque o nodos en línea dentro de ellos. Una observación aquí es que los nodos de nivel superior de un documento siempre serían nodos de bloque.
- Nodos en línea (análogos al concepto HTML de elementos en línea) que comienzan a renderizarse en la misma línea que el nodo anterior. Hay algunas diferencias en cómo se representan los elementos en línea en diferentes bibliotecas de edición. SlateJS permite que los elementos en línea sean nodos en sí mismos. DraftJS, otra biblioteca popular de edición de texto enriquecido, le permite usar el concepto de entidades para representar elementos en línea. Los enlaces y las imágenes en línea son ejemplos de nodos en línea.
- Nodos vacíos: SlateJS también permite esta tercera categoría de nodos que usaremos más adelante en este artículo para representar medios.
Si desea obtener más información sobre estas categorías, la documentación de SlateJS sobre Nodos es un buen lugar para comenzar.
Atributos
Similar al concepto de atributos de HTML, los atributos en un documento de texto enriquecido se utilizan para representar propiedades sin contenido de un nodo o sus elementos secundarios. Por ejemplo, un nodo de texto puede tener atributos de estilo de carácter que nos indiquen si el texto está en negrita/cursiva/subrayado, etc. Aunque este artículo representa los encabezados como nodos en sí mismos, otra forma de representarlos podría ser que los nodos tengan estilos de párrafo ( paragraph
& h1-h6
) como atributos en ellos.
La siguiente imagen muestra un ejemplo de cómo se describe la estructura de un documento (en JSON) a un nivel más granular utilizando nodos y atributos que resaltan algunos de los elementos de la estructura a la izquierda.
Algunas de las cosas que vale la pena mencionar aquí con la estructura son:
- Los nodos de texto se representan como
{text: 'text content'}
- Las propiedades de los nodos se almacenan directamente en el nodo (por ejemplo,
url
para enlaces ycaption
para imágenes) - La representación de atributos de texto específica de SlateJS divide los nodos de texto para que sean sus propios nodos si cambia el estilo de carácter. Por lo tanto, el texto ' Duis aute irure dolor ' es un nodo de texto propio con
bold: true
en él. Lo mismo ocurre con el texto en cursiva, subrayado y código en este documento.
Ubicaciones y Selección
Al crear un editor de texto enriquecido, es fundamental comprender cómo se puede representar la parte más granular de un documento (por ejemplo, un carácter) con algún tipo de coordenadas. Esto nos ayuda a navegar por la estructura del documento en tiempo de ejecución para comprender en qué parte de la jerarquía del documento nos encontramos. Lo que es más importante, los objetos de ubicación nos brindan una forma de representar la selección del usuario que se usa ampliamente para adaptar la experiencia del usuario del editor en tiempo real. Usaremos la selección para construir nuestra barra de herramientas más adelante en este artículo. Ejemplos de estos podrían ser:
- ¿El cursor del usuario está actualmente dentro de un enlace, tal vez deberíamos mostrarles un menú para editar/eliminar el enlace?
- ¿El usuario ha seleccionado una imagen? Tal vez les demos un menú para cambiar el tamaño de la imagen.
- Si el usuario selecciona cierto texto y presiona el botón ELIMINAR, determinamos cuál era el texto seleccionado por el usuario y lo eliminamos del documento.
El documento de SlateJS sobre Ubicación explica ampliamente estas estructuras de datos, pero las analizamos aquí rápidamente a medida que usamos estos términos en diferentes instancias del artículo y mostramos un ejemplo en el diagrama que sigue.
- Sendero
Representada por una matriz de números, una ruta es la forma de llegar a un nodo en el documento. Por ejemplo, una ruta[2,3]
representa el tercer nodo secundario del segundo nodo en el documento. - Punto
Ubicación más granular del contenido representado por ruta + desplazamiento. Por ejemplo, un punto de{path: [2,3], offset: 14}
representa el decimocuarto carácter del tercer nodo secundario dentro del segundo nodo del documento. - Rango
Un par de puntos (llamadosanchor
yfocus
) que representan un rango de texto dentro del documento. Este concepto proviene de la API de selección de Web, donde elanchor
es donde comenzó la selección del usuario y elfocus
es donde terminó. Un rango/selección contraído indica dónde los puntos de anclaje y enfoque son los mismos (piense en un cursor parpadeante en una entrada de texto, por ejemplo).
Como ejemplo, digamos que la selección del usuario en nuestro ejemplo de documento anterior es ipsum
:
La selección del usuario se puede representar como:
{ 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' }`
Configuración del editor
En esta sección, configuraremos la aplicación y obtendremos un editor de texto enriquecido básico con SlateJS. La aplicación repetitiva sería
con las dependencias de SlateJS añadidas. Estamos construyendo la interfaz de usuario de la aplicación utilizando componentes de create-react-app
. ¡Empecemos!react-bootstrap
Cree una carpeta llamada wysiwyg-editor
y ejecute el siguiente comando desde dentro del directorio para configurar la aplicación de reacción. Luego ejecutamos un comando de yarn start
que debería activar el servidor web local (el puerto predeterminado es 3000) y mostrarle una pantalla de bienvenida de React.
npx create-react-app . yarn start
Luego pasamos a agregar las dependencias de SlateJS a la aplicación.
yarn add slate slate-react
slate
es el paquete principal de SlateJS y slate slate-react
incluye el conjunto de componentes de React que usaremos para renderizar los editores de Slate. SlateJS expone algunos paquetes más organizados por funcionalidad que uno podría considerar agregar a su editor.
Primero creamos una carpeta utils
que contiene los módulos de utilidad que creamos en esta aplicación. Comenzamos con la creación de un ExampleDocument.js
que devuelve una estructura de documento básica que contiene un párrafo con algo de texto. Este módulo se ve a continuación:
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
Ahora agregamos una carpeta llamada components
que contendrá todos nuestros componentes de React y hacemos lo siguiente:
- Agregue nuestro primer componente React
Editor.js
. Solo devuelve undiv
por ahora. - Actualice el componente
App.js
para mantener el documento en su estado inicializado en nuestroExampleDocument
anterior. - Renderice el Editor dentro de la aplicación y pase el estado del documento y un controlador
onChange
al Editor para que nuestro estado del documento se actualice a medida que el usuario lo actualice. - También usamos los componentes de navegación de React bootstrap para agregar una barra de navegación a la aplicación.
El componente App.js
ahora tiene el siguiente aspecto:
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> </> );
Dentro del componente Editor, creamos una instancia del editor SlateJS y lo mantenemos dentro de un useMemo
para que el objeto no cambie entre renderizaciones.
// dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);
createEditor
nos brinda la instancia del editor
SlateJS que usamos ampliamente a través de la aplicación para acceder a selecciones, ejecutar transformaciones de datos, etc. withReact es un complemento de SlateJS que agrega comportamientos de React y DOM al objeto del editor. Los complementos de SlateJS son funciones de Javascript que reciben el objeto del editor
y le adjuntan alguna configuración. Esto permite a los desarrolladores web agregar configuraciones a su instancia del editor SlateJS de forma componible.
Ahora importamos y renderizamos los componentes <Slate />
y <Editable />
de SlateJS con el accesorio de documento que obtenemos de App.js. Slate
expone un montón de contextos de React que usamos para acceder en el código de la aplicación. Editable
es el componente que representa la jerarquía del documento para su edición. En general, el módulo
en esta etapa se ve a continuación: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> ); }
En este punto, hemos agregado los componentes necesarios de React y el editor se ha rellenado con un documento de ejemplo. Nuestro editor debería estar ahora configurado, permitiéndonos escribir y cambiar el contenido en tiempo real, como en el screencast a continuación.
Ahora, pasemos a la siguiente sección donde configuramos el editor para generar estilos de carácter y nodos de párrafo.
RENDIMIENTO DE TEXTO PERSONALIZADO Y UNA BARRA DE HERRAMIENTAS
Nodos de estilo de párrafo
Actualmente, nuestro editor usa la representación predeterminada de SlateJS para cualquier tipo de nodo nuevo que podamos agregar al documento. En esta sección, queremos poder representar los nodos de encabezado. Para poder hacer eso, proporcionamos una función renderElement
prop a los componentes de Slate. Slate llama a esta función en tiempo de ejecución cuando intenta recorrer el árbol del documento y representar cada nodo. La función renderElement obtiene tres parámetros:
-
attributes
SlateJS específico que debe aplicarse al elemento DOM de nivel superior que se devuelve desde esta función. -
element
El objeto de nodo tal como existe en la estructura del documento. -
children
Los elementos secundarios de este nodo tal como se definen en la estructura del documento.
Agregamos nuestra implementación de renderElement
a un gancho llamado useEditorConfig
donde agregaremos más configuraciones de editor a medida que avanzamos. Luego usamos el gancho en la instancia del editor dentro 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} />; } }
Dado que esta función nos da acceso al element
(que es el nodo en sí), podemos personalizar renderElement
para implementar una representación más personalizada que haga más que solo verificar element.type
. Por ejemplo, podría tener un nodo de imagen que tenga una propiedad isInline
que podríamos usar para devolver una estructura DOM diferente que nos ayude a representar imágenes en línea en lugar de imágenes en bloque.
Ahora actualizamos el componente Editor para usar este gancho como se muestra a continuación:
const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );
Con la representación personalizada en su lugar, actualizamos ExampleDocument para incluir nuestros nuevos tipos de nodos y verificar que se representen correctamente dentro del editor.
const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes
Estilos de carácter
Similar a renderElement
, SlateJS ofrece una función llamada renderLeaf que se puede usar para personalizar la representación de los nodos de texto ( Leaf
se refiere a los nodos de texto que son las hojas/los nodos de nivel más bajo del árbol del documento). Siguiendo el ejemplo de renderElement
, escribimos una implementación para 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>; }
Una observación importante de la implementación anterior es que nos permite respetar la semántica HTML para los estilos de carácter. Dado que renderLeaf nos da acceso a la leaf
del nodo de texto en sí, podemos personalizar la función para implementar una representación más personalizada. Por ejemplo, es posible que tenga una forma de permitir que los usuarios elijan un color de highlightColor
para el texto y verifiquen esa propiedad de hoja aquí para adjuntar los estilos respectivos.
Ahora actualizamos el componente Editor para usar lo anterior, el ExampleDocument
para tener algunos nodos de texto en el párrafo con combinaciones de estos estilos y verificamos que se representen como se esperaba en el Editor con las etiquetas semánticas que usamos.
# 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 }, ], },
Agregar una barra de herramientas
Comencemos agregando un nuevo componente Toolbar.js
al que agregamos algunos botones para estilos de carácter y un menú desplegable para estilos de párrafo y los conectamos más adelante en la sección.
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> ); }
Abstraemos los botones del componente ToolbarButton
que es un envoltorio alrededor del componente React Bootstrap Button. Luego representamos la barra de herramientas sobre el componente Editable
dentro del Editor
y verificamos que la barra de herramientas aparece en la aplicación.
Estas son las tres funcionalidades clave que necesitamos que admita la barra de herramientas:
- Cuando el cursor del usuario está en un lugar determinado del documento y hace clic en uno de los botones de estilo de carácter, debemos cambiar el estilo del texto que puede escribir a continuación.
- Cuando el usuario selecciona un rango de texto y hace clic en uno de los botones de estilo de carácter, debemos alternar el estilo para esa sección específica.
- Cuando el usuario selecciona un rango de texto, queremos actualizar el menú desplegable de estilo de párrafo para reflejar el tipo de párrafo de la selección. Si seleccionan un valor diferente de la selección, queremos actualizar el estilo de párrafo de toda la selección para que sea lo que seleccionaron.
Veamos cómo funcionan estas funcionalidades en el Editor antes de comenzar a implementarlas.
Escuchar la selección
Lo más importante que necesita la barra de herramientas para poder realizar las funciones anteriores es el estado de selección del documento. Al momento de escribir este artículo, SlateJS no expone un método onSelectionChange
que pueda brindarnos el último estado de selección del documento. Sin embargo, a medida que cambia la selección en el editor, SlateJS llama al método onChange
, incluso si el contenido del documento no ha cambiado. Usamos esto como una forma de recibir una notificación del cambio de selección y almacenarlo en el estado del componente Editor
. Abstraemos esto a un gancho useSelection
donde podríamos hacer una actualización más óptima del estado de selección. Esto es importante ya que la selección es una propiedad que cambia con bastante frecuencia para una instancia del Editor WYSIWYG.
import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }
Usamos este enlace dentro del componente Editor
como se muestra a continuación y pasamos la selección al componente Barra de herramientas.
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} /> ...
Consideración de rendimiento
En una aplicación en la que tenemos una base de código de Editor mucho más grande con muchas más funcionalidades, es importante almacenar y escuchar los cambios de selección de manera eficiente (como usar alguna biblioteca de administración de estado) ya que es probable que los componentes que escuchan los cambios de selección también se rendericen. con frecuencia. Una forma de hacer esto es tener selectores optimizados además del estado Selección que contienen información de selección específica. Por ejemplo, un editor podría querer representar un menú de cambio de tamaño de imagen cuando se selecciona una imagen. En tal caso, podría ser útil tener un selector isImageSelected
calculado a partir del estado de selección del editor y el menú Imagen se volvería a representar solo cuando cambie el valor de este selector. Reselect de Redux es una de esas bibliotecas que permite construir selectores.
No usamos la selection
dentro de la barra de herramientas hasta más tarde, pero pasarla como accesorio hace que la barra de herramientas se vuelva a representar cada vez que la selección cambia en el Editor. Hacemos esto porque no podemos confiar únicamente en el cambio de contenido del documento para activar una nueva representación en la jerarquía ( App -> Editor -> Toolbar
de herramientas), ya que los usuarios pueden seguir haciendo clic en el documento, cambiando así la selección pero nunca cambiando el contenido del documento. sí mismo.
Alternar estilos de carácter
Ahora pasamos a obtener los estilos de caracteres activos de SlateJS y usarlos dentro del Editor. Agreguemos un nuevo módulo JS EditorUtils
que albergará todas las funciones útiles que construimos en el futuro para obtener/hacer cosas con SlateJS. Nuestra primera función en el módulo es getActiveStyles
que proporciona un Set
de estilos activos en el editor. También agregamos una función para alternar un estilo en la función del editor — toggleStyle
:
# src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }
Ambas funciones toman el objeto del editor
que es la instancia de Slate, como parámetro, al igual que muchas funciones útiles que agregamos más adelante en el artículo. En la terminología de Slate, los estilos de formato se denominan Marcas y usamos métodos auxiliares en la interfaz del Editor para y elimine estas marcas. Importamos estas funciones útiles dentro de la barra de herramientas y las conectamos a los botones que agregamos anteriormente.
# 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
es un gancho de Slate que nos da acceso a la instancia de Slate desde el contexto en el que fue adjuntada por el componente <Slate>
más arriba en la jerarquía de representación.
Uno podría preguntarse por qué usamos onMouseDown
aquí en lugar de onClick
. Hay un problema abierto de Github sobre cómo Slate convierte la selection
en null
cuando el editor pierde el foco de alguna manera. Por lo tanto, si adjuntamos controladores onClick
a los botones de nuestra barra de herramientas, la selection
se vuelve null
y los usuarios pierden la posición del cursor al intentar alternar un estilo que no es una gran experiencia. En su lugar, alternamos el estilo adjuntando un evento onMouseDown
que evita que la selección se restablezca. Otra forma de hacer esto es hacer un seguimiento de la selección nosotros mismos para saber cuál fue la última selección y usarla para alternar los estilos. Introducimos el concepto de selección previousSelection
más adelante en el artículo, pero para resolver un problema diferente.
SlateJS nos permite configurar controladores de eventos en el Editor. Usamos eso para conectar atajos de teclado para alternar los estilos de carácter. Para hacer eso, agregamos un objeto KeyBindings
dentro useEditorConfig
donde exponemos un controlador de eventos onKeyDown
adjunto al componente Editable
. Usamos la utilidad is-hotkey
para determinar la combinación de teclas y alternar el estilo correspondiente.
# 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} />
Cómo hacer que el menú desplegable de estilo de párrafo funcione
Pasemos a hacer que el menú desplegable Estilos de párrafo funcione. De manera similar a cómo funcionan los menús desplegables de estilo de párrafo en aplicaciones populares de procesamiento de textos como MS Word o Google Docs, queremos que los estilos de los bloques de nivel superior en la selección del usuario se reflejen en el menú desplegable. Si hay un solo estilo coherente en la selección, actualizamos el valor desplegable para que sea ese. Si hay varios de ellos, establecemos el valor desplegable para que sea 'Múltiple'. Este comportamiento debe funcionar tanto para las selecciones contraídas como para las expandidas.
Para implementar este comportamiento, debemos poder encontrar los bloques de nivel superior que abarcan la selección del usuario. Para hacerlo, usamos Editor.nodes
de Slate: una función de ayuda que se usa comúnmente para buscar nodos en un árbol filtrado por diferentes opciones.
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 función auxiliar toma una instancia del Editor y un objeto de options
que es una forma de filtrar los nodos en el árbol a medida que lo atraviesa. La función devuelve un generador de NodeEntry
. Una entrada de NodeEntry
en la terminología de Slate es una tupla de un nodo y la ruta hacia él: [node, pathToNode]
. Las opciones que se encuentran aquí están disponibles en la mayoría de las funciones auxiliares de Slate. Veamos qué significa cada uno de ellos:
-
at
Puede ser una Ruta/Punto/Rango que la función auxiliar usaría para reducir el recorrido del árbol. Esto por defecto eseditor.selection
si no se proporciona. También usamos el valor predeterminado para nuestro caso de uso a continuación, ya que estamos interesados en los nodos dentro de la selección del usuario. -
match
Esta es una función de coincidencia que se puede proporcionar que se llama en cada nodo y se incluye si es una coincidencia. Usamos este parámetro en nuestra implementación a continuación para filtrar para bloquear elementos únicamente. -
mode
Permita que las funciones auxiliares sepan si estamos interesados en todos los nodos de nivel más alto o más bajoat
la función dematch
de coincidencia de ubicación dada. Este parámetro (establecido en elhighest
) nos ayuda a evitar intentar atravesar el árbol hacia arriba para encontrar los nodos de nivel superior. -
universal
Bandera para elegir entre coincidencias completas o parciales de los nodos. (El problema de GitHub con la propuesta de esta bandera tiene algunos ejemplos que lo explican) -
reverse
Si la búsqueda de nodos debe realizarse en la dirección inversa de los puntos de inicio y finalización de la ubicación que se pasa. -
voids
Si la búsqueda debe filtrar solo para anular elementos.
SlateJS expone muchas funciones auxiliares que le permiten consultar nodos de diferentes maneras, recorrer el árbol, actualizar los nodos o las selecciones de formas complejas. Vale la pena profundizar en algunas de estas interfaces (enumeradas al final de este artículo) al crear funcionalidades de edición complejas sobre Slate.
Con ese trasfondo en la función auxiliar, a continuación se muestra una implementación 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; }
Consideración de rendimiento
La implementación actual de Editor.nodes
encuentra todos los nodos en todo el árbol en todos los niveles que están dentro del rango de at
param y luego ejecuta filtros de coincidencia en él (verifique nodeEntries
y el filtrado más adelante: fuente). Esto está bien para documentos más pequeños. Sin embargo, para nuestro caso de uso, si el usuario seleccionó, digamos 3 encabezados y 2 párrafos (cada párrafo contiene, digamos, 10 nodos de texto), recorrerá al menos 25 nodos (3 + 2 + 2 * 10) e intentará ejecutar filtros. en ellos. Dado que ya sabemos que solo estamos interesados en los nodos de nivel superior, podemos encontrar los índices de inicio y fin de los bloques de nivel superior de la selección e iterar nosotros mismos. Tal lógica recorrería solo 3 entradas de nodo (2 encabezados y 1 párrafo). El código para eso sería algo como lo siguiente:
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; }
A medida que agregamos más funcionalidades a un editor WYSIWYG y necesitamos recorrer el árbol del documento con frecuencia, es importante pensar en las formas más eficaces de hacerlo para el caso de uso en cuestión, ya que la API disponible o los métodos auxiliares pueden no ser siempre los más adecuados. manera eficiente de hacerlo.
Una vez que hemos implementado getTextBlockStyle
, alternar el estilo de bloque es relativamente sencillo. Si el estilo actual no es el que el usuario seleccionó en el menú desplegable, cambiamos el estilo a ese. Si ya es lo que el usuario seleccionó, lo alternamos para que sea un párrafo. Debido a que estamos representando estilos de párrafo como nodos en la estructura de nuestro documento, alternar un estilo de párrafo esencialmente significa cambiar la propiedad de type
en el nodo. Usamos Transforms.setNodes
proporcionado por Slate para actualizar las propiedades en los nodos.
La implementación de nuestro toggleBlockType
es la siguiente:
# 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) } ); }
Finalmente, actualizamos nuestro menú desplegable Estilo de párrafo para usar estas funciones de utilidad.
#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> .... );
ENLACES
En esta sección, vamos a agregar soporte para mostrar, agregar, eliminar y cambiar enlaces. También agregaremos una funcionalidad de detector de enlaces, bastante similar a Google Docs o MS Word que escanea el texto escrito por el usuario y verifica si hay enlaces allí. Si los hay, se convierten en objetos de enlace para que el usuario no tenga que usar los botones de la barra de herramientas para hacerlo por sí mismo.
Enlaces de representación
En nuestro editor, implementaremos enlaces como nodos en línea con SlateJS. Actualizamos la configuración de nuestro editor para marcar enlaces como nodos en línea para SlateJS y también proporcionamos un componente para renderizar para que Slate sepa cómo renderizar los nodos de enlace.
# 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>
Dado que estamos renderizando LinkEditor
fuera del editor, necesitamos una forma de decirle a LinkEditor
dónde se encuentra el enlace en el árbol DOM para que pueda renderizarse cerca del editor. La forma en que hacemos esto es usar la API React de Slate para encontrar el nodo DOM correspondiente al nodo de enlace en la selección. Y luego usamos getBoundingClientRect()
para encontrar los límites del elemento DOM del enlace y los límites del componente del editor y calcular la top
e left
para el editor de enlaces. Las actualizaciones de código para Editor
y LinkEditor
son las siguientes:
# src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
# src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }
SlateJS mantiene internamente mapas de nodos a sus respectivos elementos DOM. Accedemos a ese mapa y encontramos el elemento DOM del enlace usando ReactEditor.toDOMNode
.
Como se ve en el video anterior, cuando se inserta un enlace y no tiene una URL, debido a que la selección está dentro del enlace, abre el editor de enlaces, lo que le brinda al usuario una forma de escribir una URL para el enlace recién insertado y por lo tanto, cierra el ciclo de la experiencia del usuario allí.
Ahora agregamos un elemento de entrada y un botón al LinkEditor
que le permite al usuario escribir una URL y aplicarla al nodo del enlace. Usamos el paquete isUrl
para la validación de URL.
# src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );
Con los elementos del formulario conectados, veamos si el editor de enlaces funciona como se esperaba.
Como vemos aquí en el video, cuando el usuario intenta hacer clic en la entrada, el editor de enlaces desaparece. Esto se debe a que a medida que representamos el editor de enlaces fuera del componente Editable
, cuando el usuario hace clic en el elemento de entrada, SlateJS cree que el editor ha perdido el foco y restablece la selection
para que sea null
, lo que elimina el LinkEditor
ya que isLinkActiveAtSelection
ya no es true
. Hay un problema abierto de GitHub que habla sobre este comportamiento de Slate. Una forma de resolver esto es rastrear la selección anterior de un usuario a medida que cambia y cuando el editor pierde el foco, podemos mirar la selección anterior y aún mostrar un menú del editor de enlaces si la selección anterior tenía un enlace. Actualicemos el useSelection
para recordar la selección anterior y devolverla al componente Editor.
# src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }
Luego actualizamos la lógica en el componente Editor
para mostrar el menú de enlaces incluso si la selección anterior tenía un enlace.
# 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={..} ... );
A continuación, actualizamos LinkEditor
para usar selectionForLink
para buscar el nodo del enlace, renderizar debajo de él y actualizar su URL.
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
Detección de enlaces en texto
La mayoría de las aplicaciones de procesamiento de texto identifican y convierten enlaces dentro del texto para vincular objetos. Veamos cómo funcionaría eso en el editor antes de comenzar a construirlo.
Los pasos de la lógica para habilitar este comportamiento serían:
- A medida que el documento cambia con la escritura del usuario, busque el último carácter insertado por el usuario. Si ese carácter es un espacio, sabemos que debe haber una palabra que podría haber estado antes.
- Si el último carácter era un espacio, lo marcamos como el límite final de la palabra anterior. Luego retrocedemos carácter por carácter dentro del nodo de texto para encontrar dónde comenzó esa palabra. Durante este recorrido, debemos tener cuidado de no pasar el borde del inicio del nodo hacia el nodo anterior.
- Una vez que hemos encontrado los límites de inicio y final de la palabra anterior, verificamos la cadena de la palabra y vemos si era una URL. Si lo fuera, lo convertimos en un nodo de enlace.
Nuestra lógica vive en una función util identifyLinksInTextIfAny
que vive en EditorUtils
y se llama dentro del componente onChange
in Editor
.
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
Aquí está identifyLinksInTextIfAny
con la lógica del Paso 1 implementada:
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; }
Hay dos funciones auxiliares de SlateJS que facilitan las cosas aquí.
-
Editor.before
— Nos da el punto anterior a una determinada ubicación. Toma launit
como parámetro para que podamos solicitar el carácter/palabra/bloque, etc. antes de que pase lalocation
. -
Editor.string
— Obtiene la cadena dentro de un rango.
Como ejemplo, el siguiente diagrama explica qué valores de estas variables son cuando el usuario inserta un carácter 'E' y su cursor se encuentra detrás de él.
Si el texto 'ABCDE' fuera el primer nodo de texto del primer párrafo del documento, nuestros valores de puntos serían:
cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}
Si el último carácter era un espacio, sabemos dónde comenzó: startPointOfLastCharacter.
Pasemos al paso 2 donde retrocedemos carácter por carácter hasta que encontremos otro espacio o el comienzo del nodo de texto en sí.
... 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);
Aquí hay un diagrama que muestra a dónde apuntan estos diferentes puntos una vez que encontramos que la última palabra ingresada es ABCDE
.
Tenga en cuenta que el start
y el end
son los puntos antes y después del espacio allí. De manera similar, startPointOfLastCharacter
y cursorPoint
son los puntos antes y después del espacio que el usuario acaba de insertar. Por lo tanto [end,startPointOfLastCharacter]
nos da la última palabra insertada.
Registramos el valor de lastWord
en la consola y verificamos los valores a medida que escribimos.
Ahora que hemos deducido cuál fue la última palabra que escribió el usuario, verificamos que efectivamente era una URL y convertimos ese rango en un objeto de enlace. Esta conversión es similar a cómo el botón de enlace de la barra de herramientas convierte el texto seleccionado por el usuario en un enlace.
if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }
identifyLinksInTextIfAny
se llama dentro de onChange
de Slate, por lo que no queremos actualizar la estructura del documento dentro de onChange
. Por lo tanto, colocamos esta actualización en nuestra cola de tareas con una Promise.resolve().then(..)
.
¡Veamos cómo la lógica se une en acción! Verificamos si insertamos enlaces al final, en medio o al principio de un nodo de texto.
Con eso, hemos finalizado las funcionalidades de los enlaces en el editor y pasamos a Imágenes.
Manejo de imágenes
En esta sección, nos enfocamos en agregar soporte para renderizar nodos de imágenes, agregar nuevas imágenes y actualizar títulos de imágenes. Las imágenes, en la estructura de nuestro documento, se representarían como nodos vacíos. Los nodos Void en SlateJS (análogos a los elementos Void en la especificación HTML) son tales que su contenido no es texto editable. Eso nos permite renderizar imágenes como vacíos. Debido a la flexibilidad de Slate con el renderizado, todavía podemos renderizar nuestros propios elementos editables dentro de los elementos Void, lo que haremos para la edición de leyendas de imágenes. SlateJS tiene un ejemplo que demuestra cómo puede incrustar un editor de texto enriquecido completo dentro de un elemento Void.
Para renderizar imágenes, configuramos el editor para tratar las imágenes como elementos vacíos y proporcionamos una implementación de renderizado de cómo deben renderizarse las imágenes. Agregamos una imagen a nuestro ExampleDocument y verificamos que se represente correctamente con el título.
# 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> ); }
Dos cosas para recordar al intentar renderizar nodos vacíos con SlateJS:
- El elemento DOM raíz debe tener
contentEditable={false}
configurado para que SlateJS trate su contenido de esa manera. Sin esto, a medida que interactúa con el elemento vacío, SlateJS puede intentar calcular selecciones, etc. y, como resultado, romperse. - Incluso si los nodos Void no tienen ningún nodo secundario (como nuestro nodo de imagen como ejemplo), todavía tenemos que representar
children
elementos secundarios y proporcionar un nodo de texto vacío como elemento secundario (consulteExampleDocument
de ejemplo a continuación) que se trata como un punto de selección del Vacío elemento de SlateJS
Ahora actualizamos el ExampleDocument
para agregar una imagen y verificar que aparezca con el título en el 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: "" }], }, ];
Ahora concentrémonos en la edición de subtítulos. La forma en que queremos que esto sea una experiencia fluida para el usuario es que cuando haga clic en el título, mostramos una entrada de texto donde puede editar el título. Si hacen clic fuera de la entrada o presionan la tecla RETORNO, lo tratamos como una confirmación para aplicar el título. Luego actualizamos el título en el nodo de la imagen y cambiamos el título nuevamente al modo de lectura. Veámoslo en acción para que tengamos una idea de lo que estamos construyendo.
Actualicemos nuestro componente Imagen para que tenga un estado para los modos de lectura y edición de los subtítulos. Actualizamos el estado del subtítulo local a medida que el usuario lo actualiza y cuando hace clic fuera ( onBlur
) o presiona RETURN ( onKeyDown
), aplicamos el subtítulo al nodo y cambiamos al modo de lectura nuevamente.
const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...
Con eso, la funcionalidad de edición de subtítulos está completa. Ahora pasamos a agregar una forma para que los usuarios carguen imágenes en el editor. Agreguemos un botón en la barra de herramientas que permita a los usuarios seleccionar y cargar una imagen.
# 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>
A medida que trabajamos con la carga de imágenes, el código podría crecer bastante, por lo que movemos el manejo de la carga de imágenes a un useImageUploadHandler
que emite una devolución de llamada adjunta al elemento de entrada del archivo. Discutiremos en breve acerca de por qué necesita el estado de selección previousSelection
.
Antes de implementar useImageUploadHandler
, configuraremos el servidor para poder cargar una imagen. Configuramos un servidor Express e instalamos otros dos paquetes: cors
y multer
que manejan las cargas de archivos por nosotros.
yarn add express cors multer
Luego agregamos un script src/server.js
que configura el servidor Express con cors y multer y expone un punto final /upload
en el que cargaremos la imagen.
# 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}`));
Ahora que tenemos la configuración del servidor, podemos concentrarnos en manejar la carga de la imagen. Cuando el usuario carga una imagen, pueden pasar unos segundos antes de que la imagen se cargue y tengamos una URL para ella. Sin embargo, hacemos lo que hacemos para brindarle al usuario una respuesta inmediata de que la carga de la imagen está en progreso para que sepa que la imagen se está insertando en el editor. Estos son los pasos que implementamos para que este comportamiento funcione:
- Una vez que el usuario selecciona una imagen, insertamos un nodo de imagen en la posición del cursor del usuario con un indicador
isUploading
establecido en él para que podamos mostrarle al usuario un estado de carga. - Enviamos la solicitud al servidor para subir la imagen.
- Una vez que se completa la solicitud y tenemos una URL de imagen, la configuramos en la imagen y eliminamos el estado de carga.
Comencemos con el primer paso donde insertamos el nodo de imagen. Ahora, la parte difícil aquí es que nos encontramos con el mismo problema con la selección que con el botón de enlace en la barra de herramientas. Tan pronto como el usuario hace clic en el botón Imagen en la barra de herramientas, el editor pierde el foco y la selección se vuelve null
. Si intentamos insertar una imagen, no sabemos dónde estaba el cursor del usuario. El seguimiento de la selección previousSelection
nos da esa ubicación y la usamos para insertar el nodo.
# src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }
A medida que insertamos el nuevo nodo de imagen, también le asignamos un id
de identificador usando el paquete uuid. Discutiremos en la implementación del Paso (3) por qué lo necesitamos. Ahora actualizamos el componente de imagen para usar el indicador isUploading
para mostrar un estado de carga.
{!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}
Eso completa la implementación del paso 1. Verifiquemos que podemos seleccionar una imagen para cargar, vea cómo se inserta el nodo de la imagen con un indicador de carga donde se insertó en el documento.
Pasando al paso (2), usaremos la biblioteca axois para enviar una solicitud al servidor.
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 }); }, [...]); }
Verificamos que la carga de la imagen funcione y que la imagen aparezca en la carpeta public/photos
de la aplicación. Ahora que la carga de la imagen está completa, pasamos al Paso (3) donde queremos establecer la URL en la imagen en la función resolve()
de la promesa axios. Podríamos actualizar la imagen con Transforms.setNodes
pero tenemos un problema: no tenemos la ruta al nodo de imagen recién insertado. Veamos cuáles son nuestras opciones para llegar a esa imagen:
- ¿No podemos usar
editor.selection
ya que la selección debe estar en el nodo de imagen recién insertado? No podemos garantizar esto ya que mientras se cargaba la imagen, el usuario podría haber hecho clic en otro lugar y la selección podría haber cambiado. - ¿Qué tal usar la selección
previousSelection
que usamos para insertar el nodo de imagen en primer lugar? Por la misma razón que no podemos usareditor.selection
, no podemos usarpreviousSelection
ya que es posible que también haya cambiado. - SlateJS tiene un módulo de historial que rastrea todos los cambios que ocurren en el documento. Podríamos usar este módulo para buscar en el historial y encontrar el último nodo de imagen insertado. Esto tampoco es completamente confiable si la imagen tardó más en cargarse y el usuario insertó más imágenes en diferentes partes del documento antes de que se completara la primera carga.
- Actualmente, la API de
Transform.insertNodes
no devuelve ninguna información sobre los nodos insertados. Si pudiera devolver las rutas a los nodos insertados, podríamos usar eso para encontrar el nodo de imagen preciso que debemos actualizar.
Dado que ninguno de los enfoques anteriores funciona, aplicamos una id
al nodo de imagen insertado (en el Paso (1)) y usamos la misma id
nuevamente para ubicarlo cuando se completa la carga de la imagen. Con eso, nuestro código para el Paso (3) se ve a continuación:
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. });
Con la implementación de los tres pasos completos, estamos listos para probar la carga de la imagen de principio a fin.
Con eso, hemos terminado Imágenes para nuestro editor. Actualmente, mostramos un estado de carga del mismo tamaño independientemente de la imagen. Esta podría ser una experiencia discordante para el usuario si el estado de carga se reemplaza por una imagen drásticamente más pequeña o más grande cuando se completa la carga. Un buen seguimiento de la experiencia de carga es obtener las dimensiones de la imagen antes de la carga y mostrar un marcador de posición de ese tamaño para que la transición sea perfecta. El enlace que agregamos arriba podría extenderse para admitir otros tipos de medios como video o documentos y también renderizar esos tipos de nodos.
Conclusión
En este artículo, hemos creado un editor WYSIWYG que tiene un conjunto básico de funcionalidades y algunas microexperiencias de usuario como detección de enlaces, edición de enlaces en el lugar y edición de leyendas de imágenes que nos ayudaron a profundizar con SlateJS y conceptos de edición de texto enriquecido en general. Si le interesa este espacio problemático relacionado con la edición de texto enriquecido o el procesamiento de textos, algunos de los problemas interesantes que puede abordar podrían ser:
- Colaboración
- Una experiencia de edición de texto más rica que admite alineaciones de texto, imágenes en línea, copiar y pegar, cambiar la fuente y los colores del texto, etc.
- Importación desde formatos populares como documentos de Word y Markdown.
Si desea obtener más información sobre SlateJS, aquí hay algunos enlaces que pueden ser útiles.
- Ejemplos de SlateJS
Muchos ejemplos que van más allá de lo básico y crean funcionalidades que normalmente se encuentran en editores como Search & Highlight, Markdown Preview y Mentions. - Documentos API
Referencia a muchas funciones de ayuda expuestas por SlateJS que uno podría querer tener a mano cuando se intenta realizar consultas/transformaciones complejas en objetos de SlateJS.
Por último, Slack Channel de SlateJS es una comunidad muy activa de desarrolladores web que crean aplicaciones de edición de texto enriquecido con SlateJS y un gran lugar para obtener más información sobre la biblioteca y obtener ayuda si es necesario.