OpenWebinars

Frameworks

Slate.js: El framework de editores

Slate.js es una nueva librería de JavaScript para React pensada para rivalizar con otras como Draft.js o Quill.js pero con una importante diferencia, ya que no es un editor, sino un framework para crear nuestros editores.

Pablo Huet

Pablo Huet

Experto Frontend

Lectura 8 minutos

Publicado el 7 de mayo de 2021

Compartir

No es nada infrecuente en el trabajo diario en algún producto el tener la necesidad en un momento dado de introducir un editor de texto, como los famosos CKEditor o Quill, para permitir a nuestros clientes que puedan editar un contenido concreto, ya sean páginas, artículos o incluso descripciones, que necesiten un texto más enriquecido que el texto plano que un textArea nos pueda ofrecer.

Sin embargo, en ocasiones, también ha podido ocurrir que la necesidad iba mucho más allá de introducir un simple editor de contenido enriquecido, y era necesario editar este mismo editor (valga la redundancia) para obtener un contenido más personalizado, quizás orientado a crear páginas editables con pre-visualización, o cualquier tipo de contenido editable pero interactivo. Por ellos, y para estos casos, es por lo que surge Slate.js, para actuar como un framework para construir editores de texto a medida.

Lidiando con los casos límite

Como comentábamos anteriormente, en muchas ocasiones nos hemos visto en la situación, como desarrolladores que somos, de introducir en alguno de nuestros proyectos algún tipo de capacidad de edición de texto, generalmente para casos concretos y muy generales como edición de comentarios, tarjetas o incluso anotaciones.

Sin embargo, si hemos tenido que “pelearnos” en alguna ocasión con algunas de las soluciones populares para edición de texto mencionadas anteriormente, nos habremos dado cuenta de varios factores decisivos:

  • Suelen estar muy opinionados - Como son creados como editores integrables, de manera monolítica, por lo general tienen un flujo de trabajo muy marcado que es obligatorio seguir, por lo que no suelen resultar muy flexibles ante la adaptación si esta es relativamente compleja.

  • Suelen ser poco extensibles y su extensibilidad recae en plugins o addons - Generalmente suelen tener una API extensa, pero incluso estos plugins y addons no suelen lograr completamente la personalización buscada y en muchos casos es necesario ceder y seguir el flujo de trabajo del mismo.

  • Se integran mal con los frameworks y librerías populares - Un caso es el de CKEditor, cuya integración en Angular o React está basada en bindings sobre la librería original javascript, pero esta resulta insuficiente para muchas de las funcionalidades y finalmente es necesario manejar la instancia de forma directa, con las complicaciones que ello pueda implicar.

Por todo ello, aunque estos productos tienen una cantidad increíble de funcionalidades y cuentan con una amplísima colección de extensiones, terminan quedándose cortos en los casos en los que o tenemos que introducir una funcionalidad con un flujo totalmente personalizado, o con unas capacidades ampliadas respecto a las disponibles o queremos que se sincronice perfectamente con el estado de nuestra aplicación a la hora de usar un framework o librería como React, Vue o Angular.

La solución de Slate

Es justo en este ámbito, en este conjunto de problemas, donde Slate encuentra su sitio y propone una solución novedosa para solventar estos problemas: Lanzar un framework para crear nuestros propios editores, y todo ello escrito directamente como componentes de uso en React.js.

Es decir, Slate no nos oferta un producto único de edición de textos, como los anteriormente comentados, sino que nos proporciona un set de herramientas y una API completa para que seamos nosotros mismos quienes construyamos nuestro propio producto a medida que pueda satisfacer nuestras necesidades concretas.

De esta forma, por ejemplo, para proyectos grandes podríamos crear un editor global a modo de componente exportable, que tuviera las funcionalidades justas requeridas (Como poner en negrita, historial de cambios…etc) y que pudiera ser introducido y sincronizado en cualquier parte de la aplicación.

Para lograr esto, Slate proporciona la gestión de un estado basado en un esquema JSON con una estructura de árbol, además de interfaces a modo de contrato para cada uno de los bloques base del área editable que proporciona. De esta forma podemos, siempre cumpliendo dichos contratos, extender cualquier parte del editor. Dichas interfaces para nodos que forman el árbol de estado son:

  • Nodos elemento - Se considera nodo elemento del editor a cualquier objeto en el árbol de estado que posea un atributo children compuesto por un array de nodos, es decir, que tenga un conjunto de nodos hijos. Cualquier nodo elemento puede, a su vez, contener cualquier número de hijos elemento dentro de children. Y al igual que cualquier objeto en Slate, una vez cumplido esto, pueden disponer de cualquier número adicional de atributos que queramos. Por ejemplo, si quisiéramos representar un párrafo en nuestro estado, podríamos introducir una nueva clave type y definirlo como:

    const paragraphNode = {
        type: 'paragraph',
        children: [...]
    }
    
  • Nodos de texto - Un nodo de texto, a diferencia de un nodo elemento, sólo dispone de una clave text, y deben encontrarse siempre en las “hojas” del árbol, es decir, deben de ser siempre el último nodo accesible después de navegar por los atributos children de otros nodos. En el caso de nuestro ejemplo anterior, al ser un párrafo, es lógico usar este nodo de forma inmediata de la siguiente manera:

    const paragraphNode = {
        type: 'paragraph',
        children: [
            { text: 'Esto es un texto de prueba' }
        ]
    }
    

    Si queremos introducir características como la negrita, la cursiva o el tachado podemos popular estos nodos también de claves booleanas que indiquen si estas características están activadas para este nodo. También es de mencionar que cualquier elemento puede disponer de cualquier número de nodos de texto, esto es así para permitir introducir características sólo a fragmentos del párrafo. Por ejemplo, si quisiéramos renderizar la siguiente frase: “Esto es un texto de prueba y esto está en negrita a diferencia de esto”, podríamos representarlo en el estado como:

    const paragraphNode = {
        type: 'paragraph',
        children: [
            { text: 'Esto es un texto de prueba ' },
            { text: 'y esto está en negrita ', bold: true},
            { text: 'a diferencia de esto' }
        ]
    }
    
  • Nodos vacíos- Un nodo vacío o void es cualquier elemento del árbol que, aunque editable, no dispone de texto, es decir, el usuario puede añadirlo o incluso interactuar con él pero no contiene texto o dicho texto no debe de poder ser editado por el usuario. Son iguales que los nodos elemento anteriores, pero se definen como vacíos usando un método del editor antes de cargarlo. Un ejemplo perfecto de nodo vacío sería cualquier elemento embebido, como, por ejemplo, un vídeo, donde podemos usar el enlace para introducirlo en el árbol, pero una vez renderizado su contenido no es editable como texto.

Gestionando el flujo de trabajo

Una vez disponemos de una serie de conceptos para el estado podemos adentrarnos en cómo Slate gestiona el flujo de edición para un editor cualquiera y, de qué manera, podemos lograr una personalización total en nuestro nuevo editor de texto. Para ello, debemos de tener en cuenta que todo el flujo se haya distribuido en los siguientes pasos:

Comandos y transformaciones

En primer lugar, se haya la interacción con el usuario, esto es, quizás pulsar un botón (Como el típico de formateo de negritas o cursiva) o quizás tal vez pulsar una combinación de teclas concreta. Cuando esto ocurre es de esperar que nuestro editor responda realizando una transformación, es decir, que acceda a la posición del árbol del estado donde se encuentra nuestro nodo de texto y añada, por ejemplo, un valor true al atributo bold, indicando de manera lógica que ese texto debe de representarse como negrita. Para ello podemos hacer uso de la API Transforms de Slate, donde para este caso concreto haríamos:

Transforms.setNodes(
  editor, // Nuestra instancia Editor
  { bold: true }, // El cambio a aplicar en el nodo
  {
    at: [0,0], // La localización en el árbol, [0,0] indica primer nodo texto del primer nodo elemento
    match: node => Text.isText(node), // Un helper que asegura que el nodo sea de tipo texto
    split: true, // Esto crea un nuevo nodo texto si la localización no cubre el nodo completo
  }
)

En el caso de los comandos, estos representan acciones de alto nivel a nivel programático, es decir, acciones que puede realizar directamente el usuario, ya sea saltos de línea, espacios, o borrados pero realizados directamente sobre el editor, por ejemplo:

Editor.insertText(editor, 'Un nuevo texto.') // Inserta nodo texto en el nodo elemento donde se sitúe el cursor

Editor.deleteBackward(editor, { unit: 'word' }) // Borra la última palabra del nodo texto donde se sitúe el cursor

Renderizado y renderers

Una vez que se realiza cualquier transformación, ya tenemos dentro del estado de cada nodo una serie de propiedades personalizadas que nos permiten retener su estado lógico, sin embargo, esto no tendría ninguna utilidad si Slate no nos facilitara alguna forma de poder renderizar dichos nodos teniendo en cuenta estas propiedades. Por ello aquí entran en juego los renderers de Slate, que se introducen dentro del componente raíz como una función callback, siendo por ejemplo un caso simple:

import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

const MyEditor = () => {
  const editor = useMemo(() => withReact(createEditor()), [])
  const renderElement = useCallback(({ attributes, children, element }) => {
    switch (element.type) {
      case 'paragraph':
        return <p {...attributes}>{children}</p>
      case 'link':
        return (
          <a {...attributes} href={element.url}>
            {children}
          </a>
        )
      default:
        return <p {...attributes}>{children}</p>
    }
  }, [])

  return (
    <Slate editor={editor}>
      <Editable renderElement={renderElement} />
    </Slate>
  )
}

Donde introducimos una función renderElement que simplemente renderiza cada nodo elemento basándose el el atributo type que establecimos previamente. Así si el tipo es paragraph será renderizado como un párrafo <p> de HTML.

Sin embargo, esta funcionalidad puede aprovecharse para crear nuestros propios componentes de renderización, o renderers. Por ejemplo, para un párrafo podríamos crear un nuevo componente para renderizarlo, y podemos introducir un nuevo atributo llamado selected en nuestro anterior nodo de ejemplo:

const paragraphNode = {
    type: 'paragraph',
    selected: false,
    children: [
        { text: 'Esto es un texto de prueba ' },
        { text: 'y esto está en negrita ', bold: true},
        { text: 'a diferencia de esto' }
    ]
}

Ahora si definimos nuestro componente de renderizado como:

const ParagraphRenderer = ({ element, children, attributes }) => {
    return (
        <div className="section paragraph" {...attributes}>
            {element.selected && (
                <div className="label h-label" contentEditable={false}>
                    {element.type}
                </div>
            )}
            <p>{children}</p>
        </div>
    );
};

Podemos crear un nuevo elemento dentro del renderizado que nos indique qué elemento es el que tenemos seleccionado, haciendo una transformación en el nodo que ponga a true su propiedad selected siempre que hagamos click sobre dicho nodo. De esta forma tendremos la siguiente visualización:

Prueba de renderización

Sin embargo, como podemos comprobar, el texto en negrita no se ha renderizado directamente, aunque tuviera la anotación bold. Esto es porque aún falta introducir el renderizador de “hojas”, como veremos a continuación.

Tratamiento de los nodos de texto

Como Slate es un simple framework, no ofrece por defecto la renderización de nuestros atributos booleanos en los nodos de texto. Por ello en nuestro anterior ejemplo y aunque teníamos marcado un cierto nodo de texto como negrita, la renderización no lo ha tenido en cuenta. Para este tipo de renderización Slate pone a nuestra disposición otra propiedad del componente llamada renderLeaf y que igualmente espera un callback con las reglas para renderizar cada nodo de texto.

Para nuestro caso simple no necesitamos una función muy extensa y detallada, sino aquella que resuelva las decoraciones más generales de texto, de manera que siguiendo el estilo de nuestro ejemplo anterior:

import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'
import ParagraphRenderer from './renderers/ParagraphRenderer'

const MyEditor = () => {
  const editor = useMemo(() => withReact(createEditor()), [])
  const renderElement = useCallback(({ attributes, children, element }) => {
    switch (element.type) {
      case 'paragraph':
        return <ParagraphRenderer {...attributes}>{children}</ParagraphRenderer>
      case 'link':
        return (
          <a {...attributes} href={element.url}>
            {children}
          </a>
        )
      default:
        return <p {...attributes}>{children}</p>
    }
  }, [])

  const LeafRenderer = useCallback(({ attributes, children, leaf }) => {
        let className = null;
        if (leaf.bold) {
            return <strong>{children}</strong>;
        }
        if (leaf.code) {
            return <code>{children}</code>;
        }
        if (leaf.italic) {
            return <em>{children}</em>;
        }
        if (leaf.underline) {
            return <u>{children}</u>;
        }
  }, []);

  return (
    <Slate editor={editor}>
      <Editable 
          renderElement={renderElement}
          renderLeaf={renderLeaf}
      />
    </Slate>
  )
}

De forma que ahora la renderización del nodo anterior dispondrá del coloreado en negrita de nuestro nodo de texto:

Prueba de renderización

El futuro de Slate

Actualmente Slate se encuentra en beta, y concretamente en su versión 0.59.0, y los desarrolladores ya han advertido que se avecinan múltiples cambios tanto a nivel de API como de características añadidas. A pesar de ello, y después de estar un buen tiempo trabajando con esta librería, puedo afirmar que, aunque con errores (menores en gran parte) y ciertas características que bloquean o dificultan el desarrollo, Slate se siente muy completo, y su rendimiento es increíblemente fluido, y sobre todo teniendo en cuenta la cantidad de operaciones que puede realizar en una única acción de usuario.

Prueba de renderización

Sin embargo, y porque no todo va a ser brillante, pueden notarse importantes fallas sobre todo a la hora de desarrollar productos extensos y compactos. Por ejemplo, actualmente desarrollo en ratos libres un, aún inestable, editor Markdown interactivo libre de distracciones (Captura arriba) basado en el de escritorio de Typora y he podido notar obstáculos en su desarrollo sobre todo a la hora de tratar los anteriormente denominados “nodos vacíos” o “void nodes”, ya que en la versión actual hay que tener presente que los elementos sin edición de usuario pueden provocar errores silenciosos dentro del propio motor del framework.

Aun así, y con todos los errores que aún podamos encontrar debido a su estado beta, cada vez más desarrolladores y compañías hacen uso de Slate como solución para desarrollar sus propios editores para sus productos, y su peculiar aproximación empieza a rivalizar incluso contra otras grandes soluciones basadas sólo en React de gran calibre como es Draft.js.

Compartir este post

También te puede interesar

Cómo convertirse en JavaScript developer
Blog

Cómo convertirse en JavaScript developer

En la actualidad JavaScript es uno de los lenguajes de programación más demandados. Este lenguaje de programación ha evolucionado desde sus primeras...

Angel Robledano