Componentes predefinidos para nodos editables en un editor de texto enriquecido con React

En un editor de texto enriquecido, además de dar formato al texto, se requiere la capacidad de insertar contenido como imágenes, videos o menciones. Para implementar un editor controlado, es esencial diseñar una estructura de DOM que permita encontrar y manipular estos nodos de manera precisa. Por lo tanto, se deben definir componentes predefinidos para diferentes tipos de nodos, como el nodo de ancho cero (ZeroWidth), el nodo vacío (Void) y el nodo incrustado (Embed), proporcionando comportamientos y estructuras predeterminados para extender el editor mediante plugins.

Los nodos como las imágenes son personalizados por el desarrollador, por lo que la estructura del DOM a nivel del framework no es directamente controlable. En su lugar, se debe acordar el anidamiento de componentes de orden superior (HOC) para gestionar tanto la estructura predeterminada como el comportamiento. Por ejemplo, el siguiente componente establece la estructura exterior, dejando que el desarrollador simplemente lo utilice.

const HOCComponent = () => {
  return (
    <div onMouseDown={handleDefaultBehavior}>
      <span>{"\u200B"}</span>
      <div contentEditable={false}>
        <img src={imageUrl} alt={description} />
      </div>
    </div>
  );
};

También surge el problema de la selección en nodos no textuales. Normalmente, la selección se encuentra en nodos de texto, pero en ciertos casos, como al hacer triple clic en una línea, el nodo anchor puede ser un nodo de texto al inicio de la línea y el nodo focus puede ser el nodo de la línea siguiente.

{
  "anchorNode": "nodo de texto",
  "anchorOffset": 0,
  "focusNode": "div", // <div data-node="true">...</div>
  "focusOffset": 0
}

En tales situaciones, es necesario corregir la selección para dirigirla a un nodo de texto. El objetivo es ajustar el foco al nodo de texto inicial de la siguiente línea en la posición offset: 0, lo que corresponde en el modelo a seleccionar la posición 0 del offset de ambas líneas, sincronizándola con la selección del DOM.

La lógica de búsqueda implementada está inspirada en Slate. El objetivo es encontrar un nodo hijo editable. Primero, se invoca getEditableChildAndIndex para buscar un nodo de primer nivel y determinar la dirección de la iteración posterior. Luego, se continúa la búsqueda en forma de Búsqueda en Profundidad (DFS), buscando nodos editables en cada nivel.

Cabe destacar que, tras la implementación de los cambios incrementales descritos en el artículo anterior, se debe prestar atención a la preservación de la estructura del DOM predefinido al introducir contenido. Esto incluye los componentes predefinidos en este artículo y abarca la verificación del DOM "sucio", la actualización de la selección y los hooks de renderizado. Estos temas ya se discutieron en profundidad en las secciones #8 y #9 sobre el manejo del método de entrada, por lo que no se repetirán aquí.

Nodo de ancho cero (ZeroWidth)

Un carácter de ancho cero, como su nombre indica, no tiene ancho visual. Estos caracteres pueden servir como contenido de relleno invisible para lograr efectos especiales, como el ocultamiento de información para marcas de agua o el intercambio de información cifrada. Algunos sitios de novelas utilizan este método junto con la sustitución de glifos para rastrear el plagio.

Modo de selección de texto

Aunque el carácter de ancho cero es invisible visualmente, es crucial en el editor para posicionar el cursor y lograr efectos de visualización adicionales. Esto es particularmente relevante para editores basados en ContentEditable, ya que los editores con selección personalizada podría no requerir este diseño.

Si se introduce una estructura a nivel de bloque, donde el efecto de selección de nodos como las imágenes se implementa de forma personalizada en lugar de depender del modelo de selección de texto del navegador, teóricamente no se necesitaría el carácter de ancho cero. Sin embargo, en la práctica, incluso con selección a nivel de bloque (como en documentos de Lark), se sigue necesitando el carácter de ancho cero para manejar selecciones mixtas (imagen y texto), colocándolo antes y después del nodo a nivel de bloque.

Para nuestro editor, proporcionamos un componente común para nodos Void y Embed. Se debe anotar el tipo para determinar los estilos correspondientes; por ejemplo, un carácter de ancho cero de tipo Void no debe mostrar el cursor, mientras que uno de tipo Embed sí debe mantenerlo.

/**
 * Componente de carácter de ancho cero.
 * - void hide => Nodo vacío que ocupa una línea, ej. Image.
 * - embed => Nodo incrustado, ej. Mention.
 * - enter => Marcador de posición al final de la línea, se retiene en EOLView.
 * - len => Longitud del marcador de posición del nodo vacío, se usa con Void/Embed.
 */
const ZeroSpace: React.FC<ZeroSpaceProps> = (props) => {
  return <span>{ZERO_SYMBOL}</span>;
};

Manejo del Método de Entrada (IME)

Al implementar nodos Void/Embed, generalmente se incluye un carácter de ancho cero dentro del nodo Void para manejar el mapeo de selecciones. Normalmente se oculta su posición de visualización para ocultar el cursor, pero bajo ciertas condiciones esto puede causar que la entrada del IME sea absorbida.

Una solución es colocar el identificador del carácter de ancho cero antes del EmbedNode, lo que no afecta la búsqueda de selecciones. Las implementaciones de Lark también siguen este patrón, colocando siempre el ZeroNode antes del FakeNode.

Carácter de ancho cero al final de la línea

El propósito principal del carácter de ancho cero es posicionar el cursor. Dado que la renderización de la vista es directamente equivalente a la estructura de datos, al final de cada línea siempre existe un carácter de ancho cero para corresponder al nodo Leaf que representa el \n en la estructura de datos. Las razones para esto son:

  1. Alineación con la estructura de datos: Se alinea con el estado LineState diseñado, garantizando que cada LeafState renderice un nodo del DOM. Esto es amigable con el modelo de datos y asegura que una línea vacía conserve un nodo de texto, evitando un manejo especial.
  2. Renderizado de nodos Mention: Si el último nodo de una línea es un nodo Void, el cursor no puede colocarse al final. El carácter de ancho cero al final de la línea soluciona esto.
  3. Consistencia con otras implementaciones: Editores como Lark y Etherpad (en versiones tempranas) también implementan caracteres de ancho cero al final de cada línea para manejar problemas del DOM y de selección.

Se han implementado numerosas estrategias de compatibilidad para manejar este problema. Sin embargo, si no se renderizara este nodo, no habría necesidad de manejar la corrección de selecciones y las transformaciones de selección Void mencionadas, pero se requeriría manejar otros casos para asegurar la equivalencia entre el DOM y el Modelo.

Es necesario resolver el problema de selección en líneas vacías. Si se utiliza un nodo vacío como <span></span> como nodo hijo, no habrá contenido textual real, lo que resultará en que la altura no se expanda y la selección no pueda enfocarse allí. Por lo tanto, el contenido del nodo vacío debe ser un carácter de ancho cero para permitir el enfoque de la selección.

const nodeList = [];
leafItems.forEach((leafData, idx) => {
  if (leafData.isEol) {
    // En una línea vacía solo hay un Leaf, se renderiza un nodo de marcador de posición vacío.
    if (idx === 0) {
      nodeList.push(<EOLView key={EOL_KEY} editorInstance={editor} leafState={leafData} />);
    }
    return;
  }
  nodeList.push(<LeafView key={idx} editorInstance={editor} index={idx} leafState={leafData} />);
});
return nodeList;

Si el nodo final fuera un <br />, no se necesitaría un carácter de ancho cero para resolver este problema. La selección puede colocarse en este nodo sin la necesidad de manejar los offsets 0/1. Quill maneja las líneas vacías de esta manera, lo cual es una implementación común en editores de texto enriquecido.

Sin embargo, como el nodo br no es de texto y solo tiene el offset 0, esto lleva a que la selección en un nodo Void no pueda eliminar normalmente el nodo actual dependiendo del comportamiento predeterminado, requiriendo un manejo especial para nodos no textuales. También es necesario manejar las devoluciones de llamada de las teclas de dirección para controlar el cursor, y se han reportado problemas (issues) de que el nodo br puede interrumpir la entrada del IME. Por lo tanto, por ahora se mantiene la implementación con carácter de ancho cero.

const getTextNode = (node: Node | null): Text | null => {
  if (isTextNode(node)) {
    return node as Text;
  }
  if (isElementNode(node)) {
    const firstChild = node.childNodes[0];
    if (firstChild && (isTextNode(firstChild) || isBRElement(firstChild))) {
      return firstChild as Text;
    }
  }
  return null;
};

El carácter de ancho cero al final de la línea tiene otra aplicación importante. Si la selección va desde el final de una línea hasta parte del contenido de la siguiente línea, el modelo de selección transformado obtenido mediante Selection abarca dos líneas. Al realizar operaciones como la indentación con TAB, la operación se aplicaría a múltiples líneas, pero la selección visual de color azul claro parece cubrir solo una línea, lo que parece un error. Este problema de inconsistencia entre la apariencia visual y la operación real se resuelve mediante dibujos adicionales de la selección azul claro en editores basados en Canvas.

En nuestra implementación basada en DOM, no podemos dibujar contenido directamente, por lo que utilizamos el carácter de ancho cero al final de la línea. El enfoque es corregir activamente la selección cuando se encuentra después del carácter de ancho cero, ajustándola a estar antes de él. Esto funciona bien en Chrome, pero no en Firefox.

<div contenteditable="true">
  <div><span>Línea con carácter de ancho cero al final 1</span><span>&#8203;</span></div>
  <div><span>Línea con carácter de ancho cero al final 2</span><span>&#8203;</span></div>
  <div><span>Línea con solo texto 1</span></div>
  <div><span>Línea con solo texto 2</span></div>
</div>
<script>
  document.addEventListener("selectionchange", () => {
    const currentSelection = window.getSelection();
    if (currentSelection.rangeCount < 1) return;
    const activeRange = currentSelection.getRangeAt(0);
    const { startContainer, endContainer, startOffset, endOffset, collapsed } = activeRange;
    if (startContainer?.textContent === "\u200B" && startOffset > 0) {
      currentSelection.setBaseAndExtent(startContainer, 0, endContainer, collapsed ? 0 : endOffset);
    }
  });
</script>

Componente para nodo vacío (Void)

Un nodo Void, o nodo vacío, es un nodo cuyo contenido interno es definido por el usuario y tratado como un bloque integral por el editor, incluso si contiene texto. Además de la definición en el schema, se requiere implementar un componente HOC para que los desarrolladores lo utilicen. El componente en general es bastante claro, pero necesita implementar el manejo de eventos de selección y clic.

/**
 * HOC para un nodo incrustado vacío.
 */
const VoidComponent: React.FC<VoidProps> = (props) => {
  const { context, tag: Tag = "span" } = props;
  return (
    <>
      <ZeroSpace />
      <Tag contentEditable={false}>
        {props.children}
      </Tag>
    </>
  );
};

Búsqueda de selección

El contenido interno de este componente es definido por el usuario, y el motor del editor no sabe qué contendrá. Por lo tanto, se debe implementar una lógica de búsqueda durante los cambios de selección. Esta implementación puede ser complicada, ya que requiere una iteración de arriba hacia abajo. Slate implementa esto mediante una iteración por niveles, y nosotros seguimos un enfoque similar.

getEditableChildAndIndex implementa la búsqueda de nodos hijos. Comienza en el mismo nivel, intentando buscar hacia adelante y hacia atrás, priorizando la dirección proporcionada por direction. Esto es solo una búsqueda en un mismo nivel; si no se encuentra, es necesario continuar buscando en los nodos hijos del nodo padre, nuevamente de forma iterativa por niveles y no recursiva.

// Adaptado de: https://github.com/ianstormtaylor/slate/blob/9a21251/packages/slate-dom/src/utils/dom.ts#L156
const getEditableChildAndIndex = (/* parámetros */): [DOMNode, number] => {
  while (/* condición */) {
    if (triedForward && triedBackward) {
      break;
    }
    if (currentIndex >= childNodes.length) {
      triedForward = true;
      currentIndex = startIndex - 1;
      currentDirection = 'backward';
      continue;
    }
    if (currentIndex < 0) {
      triedBackward = true;
      currentIndex = startIndex + 1;
      currentDirection = 'forward';
      continue;
    }
    childNode = childNodes[currentIndex];
    startIndex = currentIndex;
    currentIndex += currentDirection === 'forward' ? 1 : -1;
  }
};

Si se puede encontrar directamente un nodo de texto, no es necesario una búsqueda profunda. En HTML, un nodo de texto no es un DOMElement. Además, como ya se ha determinado que el nodo actual no es de texto, el valor del offset solo puede ser 0 o la longitud del desplazamiento del nodo hijo.

// Determina el nuevo offset dentro del nodo de texto.
offset = isLast && node.textContent != null ? node.textContent.length : 0;

Comportamiento predeterminado

En esencia, un editor de texto enriquecido mezcla texto e imágenes. Al implementar una imagen con un nodo Void, se puede observar que hacer clic en el nodo de la imagen no activa el cambio en la selección del DOM. Como la selección del DOM no cambia, la selección del Modelo no se actualiza, lo que lleva a problemas con el foco y la selección.

En Slate, la implementación consiste en que, al activarse el evento OnClick, se invoca activamente el método ReactEditor.toSlateNode para buscar el nodo del DOM correspondiente a data-slate-node, luego se busca el nodo Slate Node correspondiente mediante ELEMENT_TO_NODE, y finalmente se obtiene el Path correspondiente mediante ReactEditor.findPath. Si ambos puntos base son Void, se crea un range y se establece la selección del DOM más reciente.

Dado que nuestro diseño utiliza el nodo Void como componente de orden superior, podemos implementar la configuración de la selección directamente a través del evento onMouseDown. Sin embargo, aquí se vuelve a presentar un problema: el estado del nodo es \n, que se divide en tres posiciones, y nuestro Void real debería estar solo en la segunda posición. Esta posición debería considerarse como el inicio de la línea, ya que también se utiliza al presionar las teclas de dirección izquierda/derecha.

const onMouseDownHandler = () => {
  const element = nodeRef.current;
  if (!element) return;
  const leafElement = element.closest(`[${LEAF_ATTRIBUTE}]`) as HTMLElement | null;
  const leafState = editorInstance.model.getLeafState(leafElement);
  if (leafState) {
    const point = new Point(leafState.parent.index, leafState.offset + leafState.length);
    const range = new Range(point, point.clone());
    editorInstance.selection.set(range, true);
  }
};

// Caso 2: Cuando el cursor está antes del nodo data-zero-void, se corrige al final del nodo.
// [cursor][void]\n => [void][cursor]\n
const isVoidZero = isVoidZeroNode(node);
if (isVoidZero && offset === 0) {
  return new Point(lineIndex, 1);
}

En un escenario de editor, el estado seleccionado de un nodo es una función muy común. Por ejemplo, al hacer clic en un nodo de imagen, normalmente se necesita agregar un estado seleccionado al nodo de imagen. Se han considerado dos formas de implementación: usando React Context y usando una gestión de eventos integrada. Context mantiene el estado useState de la selección en la capa más externa, mientras que la gestión de eventos integrada escucha los eventos de selección internos del editor para manejar las devoluciones de llamada.

Slate utiliza Context. Cada nodo ElementComponent tiene una capa exterior con SelectedContext para gestionar el estado seleccionado. Cuando cambia el estado de selección, se reejecuta la función render. Este método es conveniente de implementar: solo se necesitan hooks predeterminados para obtener el estado seleccionado directamente en el componente renderizado. Sin embargo, este método requiere transmitir el estado selection desde la capa más externa a los componentes hijos.

Aquí utilizamos la gestión de eventos del editor para administrar la selección, porque dentro de nuestro plugin, la vista se renderiza mediante la llamada a métodos después de la instanciación. Por lo tanto, implementamos una clase que hereda de EditorPlugin y un componente de orden superior de selección. En la instancia, escuchamos los cambios de selección del editor para activar los cambios de estado del componente de orden superior, y el estado de selección del componente de orden superior puede determinarse directamente comparando la posición de la hoja (leaf) con la posición de la selección actual.

class SelectionHOC extends React.PureComponent<SelectionHOCProps, SelectionHOCState> {
  public onSelectionChange(range: Range | null) {
    const isSelected = range ? isLeafRangeIntersect(this.props.leaf, range) : false;
    if (this.state.selected !== isSelected) {
      this.setState({ selected: isSelected });
    }
  }
  public render() {
    const { selected } = this.state;
    return (
      <div className={classnames(this.props.className, selected && "editor-selected")}>
        {React.Children.map(this.props.children, child => {
          return React.isValidElement(child) ?
            React.cloneElement(child, { ...child.props, selected: selected }) :
            child;
        })}
      </div>
    );
  }
}

Luego se necesita manejar el problema del movimiento de la selección en el nodo Void. Cuando la selección está en un nodo Void, se mueve al final del carácter de ancho cero de retorno de carro (\n). Debido a que nuestra corrección de selección lo vuelve a corregir al carácter de ancho cero del nodo Void, esto resulta en que el cursor no puede moverse. Por lo tanto, es necesario controlar activamente el movimiento de la selección, vinculando eventos de teclado al nodo Void y manejando las teclas de arriba/abajo de manera controlada.

// caso TECLA_ABAJO:
const targetPoint = new Point(nextLineIndex, nextLineLength - 1);
editorInstance.selection.set(new Range(targetPoint, targetPoint.clone()), true);
// caso TECLA_ARRIBA:
const targetPoint = new Point(prevLineIndex, prevLineLength - 1);
editorInstance.selection.set(new Range(targetPoint, targetPoint.clone()), true);

Manejo del Método de Entrada (IME)

A continuación, se aborda el problema del método de entrada. Si el cursor está en un nodo Void y se presiona cualquier tecla de entrada, el contenido del nodo se convertirá en una forma inline-block. El problema aquí es que el nodo BlockVoid debería ocupar una línea por sí mismo, pero después de la entrada de contenido, el estado real se convierte en:

[Zero][input]\n

Por lo tanto, la solución más simple es bloquear el comportamiento predeterminado si el cursor está en un nodo Void y se presiona una tecla de entrada. Si se introduce contenido, no se activará la inserción de texto específico, un comportamiento consistente con el de Slate.

const operationAtRange = getOperationAtRange(editorInstance, currentSelection);
if (editorInstance.schema.isVoid(operationAtRange)) {
  return;
}

Además, es necesario manejar el caso de la entrada en chino, ya que el evento beforeinput no puede bloquear realmente el comportamiento del IME. Aunque el contenido no se puede introducir, la selección cambia. Esto también causa problemas con nuestro método toDOMRange, donde la selección se restablece a null. Por lo tanto, se necesita corregir nuevamente la selección cuando se sincroniza del DOM al Modelo.

// Caso 3: Cuando el cursor está en el nodo data-zero-void y se activa la entrada del IME, se corrige al final del nodo.
// [ xxx[cursor]]\n => [ [cursor]xxx]\n
const isVoidZero = isVoidZeroNode(node);
if (isVoidZero && offset !== 1) {
  return new Point(lineIndex, 1);
}

Además, hay un fenómeno interesante relacionado con la interacción entre ContentEditable y el IME, descubierto en un issue de Slate. Si el nodo más externo es editable, un nodo hijo específico es not editable y le sigue inmediatamente un nodo span de texto, con el cursor actual entre ambos (al final del nodo Void), al activar el IME e introducir contenido parcial, y luego mantener presionada la tecla izquierda para mover la edición del IME a la izquierda hasta el final, hará que todo el editor pierda el foco, el IME y el texto introducido desaparezcan. Si se reactiva el IME en este punto, el texto anterior reaparece. Este fenómeno solo ocurre en Chromium, y se comporta normalmente en Firefox/Safari.

<div contenteditable="true"><span contenteditable="false" style="background:#eee;">Void</span><span>!</span></div>

Este problema se solucionó en https://github.com/ianstormtaylor/slate/pull/5736. El punto clave es que la etiqueta span exterior tiene el estilo display:inline-block, y la etiqueta div interior tiene el atributo contenteditable=false.

<div contenteditable="true"><span contenteditable="false" style="background: #eee; display: inline-block;"><div contenteditable="false">Void</div></span><span>!</span></div>

Componente para nodo incrustado (Embed)

El componente para nodo incrustado (Embed) es relativamente más complejo que el componente para nodo vacío (Void), ya que el nodo Embed es un nodo en línea y, por lo tanto, necesita implementar todos los comportamientos de la selección de texto. Sin embargo, en sí mismo no es un carácter, ya que en línea también debe tratarse como un bloque en línea completo e implementar el comportamiento de selección de texto. No obstante, aquí seguimos proporcionando un componente HOC.

/**
 * HOC para nodo incrustado Embed.
 * - Bloque en línea HOC.
 */
const EmbedComponent: React.FC<EmbedProps> = (props) => {
  return (
    <>
      <ZeroSpace embed={true} />
      <span
        style={{ margin: "0 0.1px", ...props.style }}
        contentEditable={false}
      >
        {props.children}
      </span>
    </>
  );
};

Diseño de la estructura del nodo

Nuestro nodo Embed debería ser técnicamente un nodo InlineVoid, pero se le dio el alias Embed porque el nombre del componente es demasiado largo. Al implementarlo, se encontraron demasiados problemas, lo que lleva a concluir que la experiencia directa es irremplazable.

Anteriormente, siempre habíamos utilizado el motor de texto enriquecido para implementar funciones a nivel de aplicación. Aunque también había leído el código de Slate y enviado algunos PRs para resolver problemas, al intentar implementar algo similar, surgieron demasiados problemas. El problema actual es que dependemos del ContentEditable del navegador para renderizar la posición del cursor, en lugar de usar una selección personalizada, lo que nos obliga a depender de la implementación de selección del propio navegador.

Si implementamos un nodo InlineVoid y una línea contiene solo este nodo, el cursor no podrá colocarse en las posiciones circundantes, lo cual es inconsistente con el comportamiento del contenido de texto. En el siguiente ejemplo, en la línea del medio, no se puede hacer clic para colocar el cursor al final del nodo, aunque se pueda lograr con un doble clic o las teclas de dirección, pero el nodo en ese momento no está en un nodo de texto, lo que es inconsistente con nuestro diseño de selección.

<div contenteditable style="outline: none">
  <div data-node><span data-leaf><span>123</span></span></div>
  <div data-node>
    <span data-leaf><span contenteditable="false">321</span></span>
  </div>
  <div data-node><span data-leaf><span>123</span></span></div>
</div>

La solución a este problema, tanto en Quill como en Slate, es agregar caracteres de ancho cero &#xFEFF; a ambos lados del nodo Embed para posicionar el cursor. Esto es aplicable cuando el nodo Embed no tiene nodos de texto a sus lados; si hay texto a ambos lados, no se requiere este manejo especial. Si seguimos el diseño de Slate, donde existen tres posiciones seleccionables para colocar el cursor (el propio Embed y los cursores izquierdo y derecho CARET), entonces habría tres posiciones de carácter de ancho cero.

<span data-zero-enter> </span>
<span data-zero-embed> </span>
<span data-zero-enter> </span>

Además, en Slate, la estructura de datos después de normalize se corresponde estrictamente con el formato de la estructura de datos. En el ejemplo anterior, la estructura de línea de Slate sería algo como lo siguiente. Esto hace fácil entender por qué un nodo Void como una imagen también debe tener una estructura children, porque el carácter de ancho cero debe corresponderse completamente con la estructura de datos, y como se calcula como de ancho cero, el punto de caída del cursor también está en el nodo de carácter de ancho cero, asegurando así que la selección solo estará en el offset 0 del nodo de ancho cero.

[
  { "text": "" },
  { "type": "embed", "children": [{ "text": "" }] },
  { "text": "" }
]

Sin embargo, hay un problema importante: un carácter de ancho cero tiene dos posiciones de cursor, es decir, los dos offsets 0|1. En este caso, tendríamos 4 posiciones de cursor |||. Nuestro Modelo en este momento tiene solo dos nodos \n, entonces incluso si no permitimos que el cursor se coloque después de \n, apenas correspondería a tres posiciones de cursor, y lo más importante es que un carácter de ancho cero tiene dos offsets, por lo que para toDOMPoint, el mismo punto de cursor podría tener dos situaciones.

<span data-zero-enter> |</span>
<span data-zero-embed>| </span>
<span data-zero-enter> </span>

Cuando diseñamos por primera vez toDOMPoint, priorizamos colocar el cursor al final del nodo anterior. Al hacer clic al final de la línea, el cursor se ubicaría después del nodo zero-enter, con un offset de 2. Debido a nuestra corrección de selección, este offset se corregiría a 1. Entonces, nuestra selección se corregiría al primer nodo data-zero-enter con un valor de offset de 1, lo que significa que el cursor que debería haberse colocado en el último nodo data-zero-enter ahora se corrige al primer nodo, y los datos no son la posición de selección deseada.

<span data-zero-enter> |</span>
<span data-zero-embed> </span>
<span data-zero-enter> </span>

Si inicialmente no corrigiéramos el valor del offset 2 a 1, entonces la selección se establecería en la posición offset -> 1 del nodo data-zero-embed, que tampoco es la posición de cursor deseada. Por lo tanto, sigue sin ser la posición de selección correcta, y se requiere una corrección adicional.

<span data-zero-enter> </span>
<span data-zero-embed> |</span>
<span data-zero-enter> </span>

Si se realizara una modificación, podría surgir muchos problemas, principalmente porque hay dos mutaciones de nodo. Si cambiamos el enfoque reduciendo los nodos de carácter de ancho cero, es decir, eliminando el primer nodo de carácter de ancho cero, entonces no podremos mantener los tres estados de selección. Si se desea que el offset 0 de zero-embed sea el cursor izquierdo y el offset 1 sea el efecto de selección del contenido incrustado, no es fácil de implementar, ya que el cursor en sí no puede tener un estado de aparición izquierda y desaparición derecha, lo que requiere un manejo de estilo adicional.

Por lo tanto, basándonos en los problemas anteriores, el nodo data-zero-embed se convertirá en nuestro cursor izquierdo a manejar, y el cursor derecho seguirá siendo el nodo data-zero-enter. El manejo del cursor izquierdo requiere que el contenido Embed a la derecha tenga un estilo margin. En el caso predeterminado, al corregir el offset de selección del nodo \n a 1, la posición de selección predeterminada sería la siguiente.

<span data-zero-embed> |</span>
<span data-zero-enter> </span>

Sin embargo, aquí todavía hay un problema. Al hacer clic al final de la línea, el navegador transferirá la selección a la posición del lado izquierdo del cursor del nodo, lo cual claramente no es el efecto apropiado. Porque así no podemos enfocarnos al final de la línea. Por lo tanto, necesitamos establecer una lógica de procesamiento adicional para toDOMPoint, es decir, cancelar la lógica de prioridad predeterminada del offset y, en su lugar, utilizar la lógica de prioridad del nodo. Cuando el nodo es data-zero-embed y el offset es 1, se prioriza el enfoque al nodo data-zero-enter posterior con offset -> 0.

const nodeOffset = Math.max(offset - startPosition, 0);
const nextLeafNode = leafNodes[i + 1];
// CASO1: Para la misma posición de cursor, cuando dos nodos son adyacentes, existen en realidad dos expresiones
// es decir, <s>1|</s><s>1</s> / <s>1</s><s>|1</s>
// El comportamiento predeterminado del método de cálculo actual es 1, pero el nodo Embed al final requiere un carácter de ancho cero adicional para posicionar el cursor.
// Si el nodo actual es un nodo Embed, y el offset es 1, y existe un nodo siguiente,
// se necesita mover el foco al nodo siguiente y el offset a 0.
if (
  leafNode.hasAttribute(ZERO_EMBED_KEY) &&
  nodeOffset === 1 &&
  nextLeafNode &&
  nextLeafNode.hasAttribute(ZERO_SPACE_KEY)
) {
  return { node: nextLeafNode, offset: 0 };
}

En realidad, todavía hay un problema aquí: si el nodo siguiente es un nodo de texto, todavía causará que no se pueda posicionar el cursor. Por lo tanto, en realidad solo es necesario verificar que nextLeafNode exista para mover la posición de la selección. Pero aquí todavía hay un problema, porque el mismo nodo tiene dos posiciones de offset, por lo que todavía necesitamos corregir primero la posición de toModelPoint, es decir, si el cursor está después del nodo data-zero-embed, se necesita corregirlo a antes del nodo.

// Caso 4: Cuando el cursor está en el nodo data-zero-embed, se corrige a antes del nodo.
// Si no se corrige, se llevará la posición de selección cero del DOM-Point CASO1, y presionar la tecla izquierda no moverá el cursor normalmente.
// [[z][caret]]\n => [[caret][z]]\n
const isEmbedZero = isEmbedZeroNode(node);
if (isEmbedZero && offset) {
  return new Point(lineIndex, leafOffset - 1);
}

Entonces, surge un nuevo problema: cuando el cursor está a la izquierda del nodo, pretionar la tecla derecha no moverá el cursor, porque la selección será corregida de nuevo a su posición original por la lógica anterior. Por lo tanto, todavía necesitamos manejar activamente este problema en el evento onKeyDown. Cuando la selección está en el nodo data-zero-embed, al presionar la tecla derecha, se ajusta activamente la selección.

const currentSelection = getStaticSelection();
if (rightArrowPressed && currentSelection && isEmbedZeroNode(currentSelection.startContainer)) {
  event.preventDefault();
  const newFocusPoint = new Point(focusLine, focusOffset + 1);
  const isBackward = event.shiftKey && range.isCollapsed ? false : range.isBackward;
  const newAnchorPoint = event.shiftKey ? anchorPoint : newFocusPoint.clone();
  this.set(new Range(newAnchorPoint, newFocusPoint, isBackward), true);
  return;
}

Aunque parece que se han resuelto todos los problemas, las operaciones frecuentes de normalize del DOM han traído un problema oculto. Cuando una línea contiene solo un nodo Embed, no podemos usar el ratón para arrastrar y seleccionar el nodo, e incluso esta operación activa el error de selección limit predefinido por nosotros, y las impresiones de cambio de selección en la consola son muy frecuentes.

Este problema debería considerarse un problema causado por la selección controlada y la selección por arrastre no controlada. No podemos controlar el arrastre del navegador, por lo que solo podemos reducir el comportamiento controlado tanto como sea posible. Por lo tanto, debemos evitar establecer activamente la selección cuando el usuario está arrastrando, para no interrumpir el comportamiento de arrastre. También se debe tener en cuenta que en el caso de la entrada, incluso si se presiona el ratón, la selección del DOM debe actualizarse, y se debe recalibrar una vez que se suelta el ratón.

// No se actualiza la selección cuando se presiona el ratón, a menos que sea la situación 'force'.
// Si siempre se actualiza la selección, el nodo Embed en una línea sola no se puede seleccionar, se requiere no controlado.
// Si no hay control de programación 'force', al presionar el ratón e introducir, la selección del DOM se quedará rezagada.
if (!force && this.editorInstance.state.get(EDITOR_STATE.MOUSE_DOWN)) {
  return false;
}

Comportamiento de selección

En Slate, el nodo inline coloca un carácter de ancho cero dentro de un nodo vacío para lograr el estado seleccionado del propio nodo. Si ocupa una línea por sí solo, se generarán caracteres de ancho cero antes y después para posicionar el cursor. Nuestro nodo en sí no tendrá un carácter de ancho cero para seleccionar el propio nodo, y naturalmente no contendrá un carácter de ancho cero en su interior. Los caracteres de ancho cero externos son para posicionar el cursor, lo cual es consistente con el diseño semántico de cada uno.

Al arrastrar una selección, si no se arrastra a través de un nodo Embed, el navegador colocará la selección en el nodo contenteditable="false". Por lo tanto, como se puede ver de lo anterior, al buscar un nodo en Slate, se debe buscar hacia el interior normalmente, pero en realidad deberíamos buscar en nodos del mismo nivel, por lo que la implementación aquí es diferente. Por supuesto, si el punto de caída de la selección está en un nodo data-leaf, la búsqueda hacia el interior no tendrá problemas.

<!-- Slate -->
<div data-leaf="true">
  <div contenteditable="false">
    <ZeroSpace></ZeroSpace>
    <span>Mention</span>
  </div>
</div>

<!-- Nodos del mismo nivel -->
<div data-leaf="true">
  <ZeroSpace></ZeroSpace>
  <div contenteditable="false"><span>Mention</span></div>
</div>

En el diseño anterior del nodo Embed, se usan las dos posiciones 0/1 internas del carácter de ancho cero integrado como posiciones para colocar el cursor, y la posición con offset 1 se mueve en realidad al offset 0 del nodo siguiente. Entonces, si solo usamos este esquema de desplazamiento sin corregir ModelPoint, en teoría sería viable.

Sin embargo, en la operación práctica, encontramos que si los dos nodos de selección no son continuos, presionar la tecla izquierda hará que la selección se mueva de la posición node2 offset 0 a la posición node1 offset 1. Si son continuos, se moverá normalmente a la posición node1 offset len-1.

<div contenteditable style="outline: none">
  <div><span id="$1">123</span><span contenteditable="false">Embed</span><span id="$2">456</span></div>
  <div><span id="$3">123</span><span id="$4">456</span></div>
</div>
<div>
  <button id="$5">Embed</button>
  <button id="$6">Span</button>
</div>
<script>
  const selection = window.getSelection();
  document.addEventListener("selectionchange", () => {
    console.log("selection", selection?.anchorNode.parentElement, selection?.focusOffset);
  });
  document.getElementById("$5").onclick = () => selection.setBaseAndExtent(document.getElementById("$2").firstChild, 0, document.getElementById("$2").firstChild, 0);
  document.getElementById("$6").onclick = () => selection.setBaseAndExtent(document.getElementById("$4").firstChild, 0, document.getElementById("$4").firstChild, 0);
</script>

En este ejemplo, después de presionar el botón Embed y luego la tecla izquierda, el offset de cambio de selección obtiene 3, mientras que con el botón Span obtiene 2. Si colocamos directamente el nodo de carácter de ancho cero después del nodo Embed, aunque se puede resolver este problema, no se podrá colocar el cursor antes del nodo Embed.

En este punto, se necesita colocar otro carácter de ancho cero al principio, lo que hace que el manejo de interacción adicional sea aún más complicado, y en Slate también se mencionó el PR sobre el carácter de ancho cero interrumpiendo la entrada en chino del IME. En realidad, aquí también hay un problema interesante con el mapeo de selecciones: cuando el cursor está después del nodo data-zero-embed, se necesita corregirlo a antes del nodo.

Entonces, al presionar la tecla derecha, la selección será reasignada a su posición original por esta lógica de toModelPoint, es decir, L => L no cambia, y por lo tanto no se activa el Model Sel Change, mientras que la selección del DOM sería corregida nuevamente por force de offset 1 a 0.

Si ajustamos activamente la selección al presionar la tecla derecha, primero se activará Model Sel Change y luego UpdateDOM, y luego el DOM Sel Change corregirá la selección. En este momento, como la selección no está en el carácter de ancho cero Embed, no se activará la lógica de corrección, por lo que el movimiento de la selección puede ocurrir normalmente.

// CASO2: Cuando hay contenido antes del elemento Embed y el cursor está al final del nodo, se necesita corregir al nodo Embed.
// <s>1|</s><e> </e> => <s>1</s><e>| </e>
if (nodeOffset === len && nextLeaf && nextLeaf.hasAttribute(ZERO_EMBED_KEY)) {
  return { node: nextLeaf, offset: 0 };
}
// [[cursor]embed]\n => derecha => [embed[cursor]]\n => [[cursor]embed]\n
// SET(1) => [embed[cursor]]\n => [embed][[cursor]\n] => SET(1) => EQUAL

Se puede observar que el comportamiento de selección del Embed tiene demasiados puntos a considerar. Aunque el diseño anterior es básicamente viable, surgen algunos problemas en el uso práctico. Por ejemplo, al seleccionar de izquierda a derecha, si se arrastra el ratón hacia la izquierda del nodo Embed, la selección del navegador no cubre el nodo, pero al soltar el ratón, el cálculo lo cubrirá por completo. Esto es un problema de desincronización de selección.

La causa de este problema es que nuestro diseño del modelo de selección está orientado al nodo de ancho cero izquierdo. Si se colocara a la derecha, podría causar problemas con el método de entrada, como se mencionó en slate#5685 y slate#5736. Por lo tanto, en nuestra implementación del editor, lo colocamos directamente a la izquierda del nodo incrustado.

<span data-leaf="true">
  <span data-zero-space="true" data-zero-embed="true">&ZeroWidthSpace;</span>
  <span contenteditable="false" data-void="true">{/** contenido */}</span>
</span>

Por lo tanto, en la implementación anterior, aunque todo el comportamiento de selección tiende a inclinarse hacia el nodo izquierdo, la selección en el lado derecho del nodo Embed está inclinada hacia la derecha, porque este diseño hace que el cursor del navegador siempre se muestre en el lado izquierdo. Esta implementación se mantiene como está. Cabe mencionar que la implementación de slate agrega nodos de ancho cero adicionales como selección, por lo que no existen problemas similares.

// Observación del cambio de selección
document.onselectionchange = () => {
  const currentSelection = document.getSelection();
  console.log(currentSelection.anchorNode, currentSelection.anchorOffset, currentSelection.focusNode, currentSelection.focusOffset);
};

Las modificaciones aquí se realizan principalmente para el Caso 4. El principio principal es que cuando cae en un carácter de ancho cero, el cursor se coloca antes del nodo, independientemente de si el offset es 0 o 1. Por supuesto, cuando el offset es 0, ya es una selección izquierda y no requiere un manejo especial. La posición data-void indica que la selección debe colocarse después del nodo, alineándose así con la implementación del navegador.

Y se implementa el Caso 5. Dado que el nodo Embed no implementa user-select: none, al arrastrar la selección, el offset puede exceder el límite y causar un desplazamiento. Además, si la selección no está colapsada, se necesita determinar si es el nodo End para decidir el límite y seleccionar todo el nodo Embed, para lo cual se deben transmitir los estados de entorno relevantes.

// Caso 4: Cuando el cursor está en el nodo data-zero-embed, se corrige a antes del nodo.
// Si no se corrige, se llevará la posición de selección cero del DOM-Point CASO1, y presionar la tecla izquierda no moverá el cursor normalmente.
// El principio principal es que cuando cae en un carácter de ancho cero, el cursor se coloca antes del nodo, la posición `div` indica después del nodo, alineándose con el navegador.
// [[z][caret]]\n => [[caret][z]]\n
// [[z][div[caret]]][123]\n => [[z][div]][[caret]123]\n
// Caso 5: Cuando el cursor está dentro del nodo Embed, el cursor puede estar en su texto interno (el offset puede ser > 1)
// Ya sea que la selección esté colapsada o no, si no se corrige, la selección se desbordará, lo que causará un desplazamiento al arrastrar la selección.
// Si la selección no está colapsada, se necesita determinar si es el nodo End para decidir el límite, seleccionando todo el nodo Embed.
// [embed[caret > 1]] => [embed[caret = 1]]
if (leafModel && leafModel.embed && offset >= 1) {
  const isEmbedZeroContainer = isEmbedZeroNode(nodeContainer);
  if (isEmbedZeroContainer && nodeOffset) {
    return new Point(lineIndex, leafModel.offset);
  }
  if (!isCollapsed && isTextNode(nodeContainer)) {
    return new Point(lineIndex, leafModel.offset + (isEndNode ? 1 : 0));
  }
  return new Point(lineIndex, leafModel.offset + 1);
}

Además, si hay nodos Embed continuos, el triple clic del navegador solo seleccionará el primer nodo, y los nodos subsiguientes no se cubrirán. Al cambiar la selección, se obtendrá el nodo Embed div. Por lo tanto, necesitamos determinar adicionalmente el comportamiento de selección al hacer triple clic, seleccionando activamente toda la línea sin depender del cambio de selección del navegador.

protected onTripleClick(event: MouseEvent) {
  if (event.detail !== 3 || !this.currentRange) {
    return;
  }
  const lineIndex = this.currentRange.start.line;
  const lineState = this.editorInstance.state.block.getLine(lineIndex);
  if (!lineState) {
    return;
  }
  const targetRange = Range.fromTuple([lineState.index, 0], [lineState.index, lineState.length - 1]);
  this.set(targetRange, true);
  event.preventDefault();
}

Manejo del Método de Entrada (IME)

Dentro de un nodo Embed, el método de entrada (IME) también puede causar impactos adicionales. Además de los problemas mencionados anteriormente, hay un caso adicional que necesita resolución. Cuando el texto existe solo en una línea y solo hay un nodo Embed, introducir contenido al principio del nodo causará que el contenido se duplique, es decir, el IME hará que el contenido introducido se quede atrapado en el carácter de ancho cero.

[Zero<IME Text>][<IME Text>]\n

En este caso, se debe corregir el contenido de texto en el carácter de ancho cero después de que la entrada del IME se complete. Dado que ya hemos implementado la corrección de texto de la línea, solo necesitamos corregir el contenido de texto en el carácter de ancho cero después de que la entrada del IME se complete.

const isZeroNode = !!zeroNode;
const textNode = isZeroNode ? zeroNode : LEAF_TO_TEXT.get(leafNode);
const textContent = isZeroNode ? ZERO_SYMBOL : leafNode.getText();
// Si el contenido de texto no es válido, generalmente debido al DOM "sucio" de la entrada, se necesita corregir.
if (isTextNode(textNode.firstChild)) {
  // Caso1: [inline-code][caret][text] IME causará una diferencia entre modelo/texto
  // Caso3: Cuando solo hay un nodo Embed en una sola línea, introducir al principio del nodo causará contenido duplicado
  if (textNode.firstChild.nodeValue === textContent) return false;
  textNode.firstChild.nodeValue = textContent;
}

Etiquetas: RichTextEditor React ZeroWidth VoidNode EmbedNode

Publicado el 6-13 21:23