Índice
En este artículo, aprenderemos cómo crear un editor de texto enriquecido/WYSIWYG que admita texto enriquecido, imágenes, enlaces y algunas características matizadas de aplicaciones de procesamiento de textos. Usaremos SlateJS para construir el shell del editor y luego agregaremos una barra de herramientas y configuraciones personalizadas. El código de la aplicación está disponible en GitHub como referencia.
En los últimos años, el campo de la creación y representación de contenidos en plataformas digitales ha experimentado una disrupción masiva. El éxito generalizado de productos como Quip, Google Docs y Dropbox Paper ha demostrado cómo las empresas se apresuran a 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, existe una nueva ola de creadores de contenido independientes que utilizan plataformas como Medium para crear contenido y compartirlo con su audiencia.
Dado que tantas personas de diferentes profesiones y orígenes 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 la creación de un editor, sino también brindarles a los lectores una idea de cómo las pequeñas funcionalidades, cuando se combinan, pueden crear una excelente experiencia de usuario para un creador de contenido.
Comprender la estructura del documento
Antes de sumergirnos en la construcció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 hijos dentro de ellos (por ejemplo, los nodos de párrafo contienen nodos de texto dentro de ellos). Los nodos también contienen propiedades específicas del objeto que representan que son necesarias para representar esos nodos dentro del editor. (por ejemplo, los nodos de imagen contienen una src
propiedad de imagen, los bloques de código pueden contener una language
propiedad, etc.).
Existen en gran medida dos tipos de nodos que representan cómo deben representarse:
- Nodos de bloque (análogos al concepto HTML de elementos a nivel de bloque) que se representan cada uno en una nueva línea y ocupan el ancho disponible. Los nodos de bloque podrían 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án 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. Existen 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. DraftJS, otra biblioteca popular de edición de texto enriquecido, le permite utilizar 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
De manera 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 hijos. Por ejemplo, un nodo de texto puede tener atributos de estilo de carácter que nos indican si el texto está en negrita/cursiva/subrayado, etc. Aunque este artículo representa los encabezados como nodos en sí, otra forma de representarlos podría ser que los nodos tengan estilos de párrafo ( paragraph
h1-h6
) como atributos.
La imagen siguiente 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 de 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 ecaption
imágenes) - La representación específica de SlateJS de los atributos de texto divide los nodos de texto para que sean sus propios nodos si el estilo del carácter cambia. Por lo tanto, el texto ' Duis aute irure dolor ' es un nodo de texto propio con
bold: true
set. Lo mismo ocurre con el texto en cursiva, subrayado y estilo de 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 más importante es que los objetos de ubicación nos brindan una forma de representar la selección del usuario que se utiliza 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 se encuentra actualmente dentro de un enlace? ¿Quizás deberíamos mostrarle un menú para editar/eliminar el enlace?
- ¿El usuario ha seleccionado una imagen? Quizás 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 fue 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 repasamos aquí rápidamente a medida que usamos estos términos en diferentes momentos del artículo y mostramos un ejemplo en el diagrama que sigue.
- Ruta
Representada por una serie 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 del documento. - Punto
Ubicación más granular del contenido representada por ruta + desplazamiento. Por ejemplo, un punto{path: [2,3], offset: 14}
representa el carácter 14 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, dondeanchor
es donde comienza la selección del usuario yfocus
donde termina. Un rango/selección colapsado indica dónde los puntos de anclaje y de 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
:
ipsum
. ( Vista previa grande )
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'}`
Configurando el editor
En esta sección, configuraremos la aplicación y obtendremos un editor de texto enriquecido básico con SlateJS. La aplicación estándar tendría
dependencias de SlateJS agregadas. 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 yarn start
comando 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 e 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 utils
carpeta que contiene todos los módulos de utilidad que creamos en esta aplicación. Comenzamos creando un ExampleDocument.js
que devuelve una estructura de documento básica que contiene un párrafo con algo de texto. Este módulo se parece 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
. Sólo devuelve adiv
por ahora. - Actualice el
App.js
componente para mantener el documento en su estado inicializado alExampleDocument
anterior. - Represente el Editor dentro de la aplicación y pase el estado del documento y un
onChange
controlador al Editor para que el estado de nuestro documento se actualice a medida que el usuario lo actualiza. - También utilizamos los componentes Nav de React bootstrap para agregar una barra de navegación a la aplicación.
App.js
El componente ahora se ve así:
import Editor from './components/Editor';function App() { const [document, updateDocument] = useState(ExampleDocument); return ( Navbar bg="dark" variant="dark" Navbar.Brand href="#" img src="/app-icon.png" 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 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 proporciona la editor
instancia de SlateJS que utilizamos ampliamente en la aplicación para acceder a selecciones, ejecutar transformaciones de datos, etc. withReact es un complemento de SlateJS que agrega comportamientos React y DOM al objeto editor. Los complementos de SlateJS son funciones de Javascript que reciben el editor
objeto 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 Slate /
componentes Editable /
de SlateJS con el accesorio del 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 está lleno de un documento de ejemplo. Nuestro editor ahora debería estar configurado, permitiéndonos escribir y cambiar el contenido en tiempo real, como se muestra en el screencast a continuación.
Estilos de personajes
De manera similar a renderElement
, SlateJS proporciona una función llamada renderLeaf que se puede usar para personalizar la representación de los nodos de texto ( Leaf
refiriéndose a los nodos de texto que son las hojas/nodos de nivel más bajo del árbol del documento). Siguiendo el ejemplo de renderElement
, escribimos una implementación para renderLeaf
. Blog sobre música Rock
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 caracteres. Dado que renderLeaf nos da acceso al nodo de texto leaf
en sí, podemos personalizar la función para implementar una representación más personalizada. Por ejemplo, es posible que tenga una manera de permitir que los usuarios elijan un highlightColor
texto y verifiquen la propiedad de la hoja aquí para adjuntar los estilos respectivos.
Ahora actualizamos el componente Editor para usar lo anterior, ExampleDocument
tener algunos nodos de texto en el párrafo con combinaciones de estos estilos y verificar que se representen como se esperaba en el Editor con las etiquetas semánticas que usamos.
# src/components/Editor.jsconst { 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 caracteres 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 );}
Extraemos los botones del ToolbarButton
componente que envuelve el componente del botón React Bootstrap. Luego representamos la barra de herramientas sobre el componente Editable
interno Editor
y verificamos que la barra de herramientas aparezca 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 alternar 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 el que seleccionaron.
Veamos cómo funcionan estas funcionalidades en el Editor antes de comenzar a implementarlas.
is one such library that enables building selectors.
We don’t use selection
inside the toolbar until later but passing it down as a prop makes the toolbar re-render each time the selection changes on the Editor. We do this because we cannot rely solely on the document content change to trigger a re-render on the hierarchy (App - Editor - Toolbar
) as users might just keep clicking around the document thereby changing selection but never actually changing the document content itself.
Toggling Character Styles
We now move to getting what the active character styles are from SlateJS and using those inside the Editor. Let’s add a new JS module EditorUtils
that will host all the util functions we build going forward to get/do stuff with SlateJS. Our first function in the module is getActiveStyles
that gives a Set
of active styles in the editor. We also add a function to toggle a style on the editor function — toggleStyle
:
# src/utils/EditorUtils.jsimport { 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); }}
Both the functions take the editor
object which is the Slate instance as a parameter as will a lot of util functions we add later in the article.In Slate terminology, formatting styles are called Marks and we use helper methods on Editor interface to get, add and remove these marks.We import these util functions inside the Toolbar and wire them to the buttons we added earlier.
# src/components/Toolbar.jsimport { 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
is a Slate hook that gives us access to the Slate instance from the context where it was attached by the lt;Slate
component higher up in the render hierarchy.
One might wonder why we use onMouseDown
here instead of onClick
? There is an open Github Issue about how Slate turns the selection
to null
when the editor loses focus in any way. So, if we attach onClick
handlers to our toolbar buttons, the selection
becomes null
and users lose their cursor position trying to toggle a style which is not a great experience. We instead toggle the style by attaching a onMouseDown
event which prevents the selection from getting reset. Another way to do this is to keep track of the selection ourselves so we know what the last selection was and use that to toggle the styles. We do introduce the concept of previousSelection
later in the article but to solve a different problem.
SlateJS allows us to configure event handlers on the Editor. We use that to wire up keyboard shortcuts to toggle the character styles. To do that, we add a KeyBindings
object inside useEditorConfig
where we expose a onKeyDown
event handler attached to the Editable
component. We use the is-hotkey
util to determine the key combination and toggle the corresponding style.
# src/hooks/useEditorConfig.jsexport 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} /
— A helper function commonly used to search for nodes in a tree filtered by different options.
nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatchT mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) = GeneratorNodeEntryT, void, undefined
The helper function takes an Editor instance and an options
object that is a way to filter nodes in the tree as it traverses it. The function returns a generator of NodeEntry
. A NodeEntry
in Slate terminology is a tuple of a node and the path to it — [node, pathToNode]
. The options found here are available on most of the Slate helper functions. Let’s go through what each of those means:
at
This can be a Path/Point/Range that the helper function would use to scope down the tree traversal to. This defaults toeditor.selection
if not provided. We also use the default for our use case below as we’re interested in nodes within user’s selection.match
This is a matching function one can provide that is called on each node and included if it is a match. We use this parameter in our implementation below to filter to block elements only.mode
Let’s the helper functions know if we’re interested in all, highest-level or lowest level nodesat
the given location matchingmatch
function. This parameter (set tohighest
) helps us escape trying to traverse the tree up ourselves to find the top-level nodes.universal
Flag to choose between full or partial matches of the nodes. (GitHub Issue with the proposal for this flag has some examples explaining it)reverse
If the node search should be in the reverse direction of the start and end points of the location passed in.voids
If the search should filter to void elements only.
SlateJS exposes a lot of helper functions that let you query for nodes in different ways, traverse the tree, update the nodes or selections in complex ways. Worth digging into some of these interfaces (listed towards the end of this article) when building complex editing functionalities on top of Slate.
With that background on the helper function, below is an implementation of 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;}
Performance Consideration
The current implementation of Editor.nodes
finds all the nodes throughout the tree across all levels that are within the range of the at
param and then runs match filters on it (check nodeEntries
and the filtering later — source). This is okay for smaller documents. However, for our use case, if the user selected, say 3 headings and 2 paragraphs (each paragraph containing say 10 text nodes), it will cycle through at least 25 nodes (3 + 2 + 2*10) and try to run filters on them. Since we already know we’re interested in top-level nodes only, we could find start and end indexes of the top level blocks from the selection and iterate ourselves. Such a logic would loop through only 3 node entries (2 headings and 1 paragraph). Code for that would look something like below:
export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex = endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType;}
As we add more functionalities to a WYSIWYG Editor and need to traverse the document tree often, it is important to think about the most performant ways to do so for the use case at hand as the available API or helper methods might not always be the most efficient way to do so.
Once we have getTextBlockStyle
implemented, toggling of the block style is relatively straightforward. If the current style is not what user selected in the dropdown, we toggle the style to that. If it is already what user selected, we toggle it to be a paragraph. Because we are representing paragraph styles as nodes in our document structure, toggle a paragraph style essentially means changing the type
property on the node. We use Transforms.setNodes
provided by Slate to update properties on nodes.
Our toggleBlockType
’s implementation is as below:
# src/utils/EditorUtils.jsexport function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) = Editor.isBlock(editor, n) } );}
Finally, we update our Paragraph-Style dropdown to use
Tal vez te puede interesar:
- Creación de su propia biblioteca de validación de React: las características (Parte 2)
- Introducción a Quasar Framework: creación de aplicaciones multiplataforma
- Creación de un componente web retro que se puede arrastrar con iluminación
- Creación y acoplamiento de una aplicación Node.js con arquitectura sin estado con la ayuda de Kinsta
Creación de un editor de texto enriquecido (WYSIWYG)
Nodos de documentoAtributosUbicaciones y selecciónEstilos de personajesToggling Character StylesTal vez te puede interesar:Register! Deploy Fast. Deploy Smar
programar
es
https://aprendeprogramando.es/static/images/programar-creacion-de-un-editor-de-texto-enriquecido-wysiwyg-1099-0.jpg
2024-12-03
Si crees que alguno de los contenidos (texto, imagenes o multimedia) en esta página infringe tus derechos relativos a propiedad intelectual, marcas registradas o cualquier otro de tus derechos, por favor ponte en contacto con nosotros en el mail [email protected] y retiraremos este contenido inmediatamente