Creación de un editor de texto enriquecido (WYSIWYG)

📅 15/01/2025 👤 Julio Fuente 📂 programar
  • Register!
  • Deploy Fast. Deploy Smart

  • Índice
    1. Comprender la estructura del documento
      1. Nodos de documento
      2. Atributos
      3. Ubicaciones y selección
    2. Configurando el editor
      1. Estilos de personajes
    3. Agregar una barra de herramientas
      1. Toggling Character Styles

    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.

    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 srcpropiedad de imagen, los bloques de código pueden contener una languagepropiedad, etc.).

    Existen en gran medida dos tipos de nodos que representan cómo deben representarse:

    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.

    Documento de ejemplo y su representación estructural. ( Vista previa grande )

    Algunas de las cosas que vale la pena mencionar aquí con la estructura son:

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

    Como ejemplo, digamos que la selección del usuario en nuestro ejemplo de documento anterior es ipsum:

    El usuario selecciona la palabra 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'}`

    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 create-react-appdependencias de SlateJS agregadas. Estamos construyendo la interfaz de usuario de la aplicación utilizando componentes de react-bootstrap. ¡Empecemos!

    Cree una carpeta llamada wysiwyg-editory ejecute el siguiente comando desde dentro del directorio para configurar la aplicación de reacción. Luego ejecutamos un yarn startcomando 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

    slatees el paquete principal de SlateJS e slate-reactincluye 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 utilscarpeta que contiene todos los módulos de utilidad que creamos en esta aplicación. Comenzamos creando un ExampleDocument.jsque 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 componentsque contendrá todos nuestros componentes de React y hacemos lo siguiente:

    App.jsEl 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 useMemopara 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()), []);

    createEditornos proporciona la editorinstancia 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 editorobjeto 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. Slateexpone un montón de contextos de React que usamos para acceder en el código de la aplicación. Editablees el componente que representa la jerarquía del documento para su edición. En general, el Editor.jsmódulo en esta etapa se ve a continuación:

    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.

    Nodos de encabezados y párrafos en el editor. ( Vista previa grande )

    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 ( Leafrefirié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.

    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 leafen 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 highlightColortexto y verifiquen la propiedad de la hoja aquí para adjuntar los estilos respectivos.

    Ahora actualizamos el componente Editor para usar lo anterior, ExampleDocumenttener 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 },    ],  },

    Estilos de caracteres en la interfaz de usuario y cómo se representan en el árbol DOM. ( Vista previa grande )

    Comencemos agregando un nuevo componente Toolbar.jsal 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 ToolbarButtoncomponente que envuelve el componente del botón React Bootstrap. Luego representamos la barra de herramientas sobre el componente Editableinterno Editory verificamos que la barra de herramientas aparezca en la aplicación.

    Barra de herramientas con botones ( vista previa grande )

    Estas son las tres funcionalidades clave que necesitamos que admita la barra de herramientas:

    1. 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.
    2. 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.
    3. 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:

    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:

    1. Creación de su propia biblioteca de validación de React: las características (Parte 2)
    2. Introducción a Quasar Framework: creación de aplicaciones multiplataforma
    3. Creación de un componente web retro que se puede arrastrar con iluminación
    4. Creación y acoplamiento de una aplicación Node.js con arquitectura sin estado con la ayuda de Kinsta