Implementación de Componentes No Editables en un Editor de Texto Enriquecido con React

En el desarrollo de un editor de texto enriquecido, una vez definidos los componentes editables como los nodos de texto o elementos incrustados, es crucial abordar la renderización de los componentes no editables. Estos incluyen elementos como marcadores de posición, la vista de solo lectura, el sistema de plugins para estilos compuestos y la gestión de nodos auxiliares externos.

Marcadores de posición (Placeholder)

Cuando el área de edición está vacía, es común mostrar un texto guía. A diferencia de los input nativos, el contentEditable no soporta esto de forma intrínseca. Una implementación eficaz utiliza pseudo-elementos CSS para inyectar el contenido, lo que evita que el marcador sea seleccionable o interactúe con el modelo de selección del editor.

.editor-container [data-placeholder]::before {
  content: attr(data-placeholder);
  color: #a0a0a0;
  position: absolute;
  pointer-events: none;
}

En un entorno React, se puede lograr un comportamiento similar mediante un componnete dedicado que se muestra condicionalmente. Este componente debe estar fuera del flujo del documento y ser impermeable a los eventos y la selección del usuario.

const PlaceholderDisplay = ({ text, editorState }) => {
  const isVisible = editorState.isEmpty && !editorState.isComposing;
  return isVisible ? (
    <div classname="absolute opacity-30 select-none pointer-events-none" data-placeholder="">
      {text}
    </div>
  ) : null;
};

Modo de solo lectura

Activar el modo de solo lectura es, en esencia, establecer la propiedad contentEditable del contenedor principal a false. Sin embargo, para que otros componentes como barras de herramientas o paneles de edición reaccionen a este cambio, el estado debe propagarse de manera controlada a través del árbol de componentes.

Se utiliza un Contexto de React para distribuir el estado booleano de solo lectura.

const EditorContext = createContext({ readOnly: false });
EditorContext.displayName = 'EditorContext';

const useEditorConfig = () => useContext(EditorContext);

const EditorWrapper = ({ readOnly, children }) => (
  <editorcontext.provider readonly="readonly" value="{{">
    {children}
  </editorcontext.provider>
);

El componente raíz sincroniza esta propiedad con el estado interno del editor.

const EditorRoot = ({ readOnly }) => {
  useEffect(() => {
    editor.setState({ isReadOnly: !!readOnly });
  }, [readOnly, editor]);

  return (
    <div contenteditable="{!readOnly}">
      {/* Contenido del editor */}
    </div>
  );
};

Renderizado de plugins para nodos compuestos

Ciertos estilos, como los enlaces hipervínculo, requieren envolver múltiples segmentos de texto en un único elemento contenedor. Un enfoque basado en hojas individuales produciría HTML fraccionado y poco semántico. Se necesita un sistema de envoltura (wrapper) que identifique y agrupe segmentos adyacentes que comparten los mismos atributos de estilo "contenedor".

El algoritmo básico consiste en generar una "firma" para cada hoja, basada en los atributos que requieren un envoltorio. Luego, se itera secuencialmente fusionando hojas contiguas con idéntica firma.

function getNodeWrapperSignature(nodeAttributes: Record<string any="">, wrapperKeys: string[]): string {
  return wrapperKeys
    .filter(key => nodeAttributes[key] !== undefined)
    .map(key => `${key}:${nodeAttributes[key]}`)
    .join('|');
}

function mergeNodesForWrapping(leaves: LeafNode[], keysToWrap: string[]): MergedGroup[] {
  const groups: MergedGroup[] = [];
  let i = 0;
  while (i < leaves.length) {
    const currentLeaf = leaves[i];
    const currentSig = getNodeWrapperSignature(currentLeaf.attrs, keysToWrap);
    if (!currentSig) {
      groups.push({ nodes: [currentLeaf], signature: '' });
      i++;
      continue;
    }

    const groupNodes = [currentLeaf];
    let j = i + 1;
    while (j < leaves.length && getNodeWrapperSignature(leaves[j].attrs, keysToWrap) === currentSig) {
      groupNodes.push(leaves[j]);
      j++;
    }
    groups.push({ nodes: groupNodes, signature: currentSig });
    i = j;
  }
  return groups;
}</string>

Posteriormente, cada grupo se pasa a la función de renderizado del plugin correspondiente, la cual es responsable de envolver el contenido renderizado de los nodos hijos.

Incorporación de nodos auxiliares externos (Portal)

Componentes como menús de sugerencias o paneles de edición contextual no pueden vivir dentro del contentEditable del editor, ya que podrían ser eliminados por el navegador o interferir con la selección. La solución es montarlos en un nodo DOM separado, típicamente un div hermano del editor.

Para integrarlos con React y el sistema de estado del editor, se puede utilizar ReactDOM.createPortal para renderizar el comopnente en un destino externo, combinado con un estado local que gestione los portales activos.

const PortalManager = ({ editor }) => {
  const [activePortals, setActivePortals] = useState(new Map());

  useEffect(() => {
    editor.portalRegistry = setActivePortals;
    return () => { editor.portalRegistry = null; };
  }, [editor]);

  return (
    <>
      <div id="editor-mount-point"></div>
      {Array.from(activePortals.entries()).map(([key, portalNode]) => (
        <react.fragment key="{key}">{portalNode}</react.fragment>
      ))}
    >
  );
};

// Uso dentro de un plugin o componente
const SuggestMenu = ({ editor, position }) => {
  const menuContent = <div classname="suggest-menu">...</div>;
  return ReactDOM.createPortal(menuContent, document.getElementById('editor-mount-point'));
};

Etiquetas: React TypeScript Rich Text Editor State Management Component Architecture

Publicado el 6-3 00:02