Adición de un sistema de comentarios a un editor WYSIWYG

Publicado: 2022-03-10
Resumen rápido ↬ En este artículo, reutilizaremos el editor WYSIWYG fundamental integrado en el primer artículo para crear un sistema de comentarios para un editor WYSIWYG que permita a los usuarios seleccionar texto dentro de un documento y compartir sus comentarios sobre él. También incorporaremos RecoilJS para la gestión de estado en la aplicación de interfaz de usuario. (El código para el sistema que construimos aquí está disponible en un repositorio de Github como referencia).

En los últimos años, hemos visto a Collaboration penetrar muchos flujos de trabajo digitales y casos de uso en muchas profesiones. Solo dentro de la comunidad de diseño e ingeniería de software, vemos que los diseñadores colaboran en artefactos de diseño usando herramientas como Figma, equipos que hacen Sprint y planificación de proyectos usando herramientas como Mural y entrevistas realizadas usando CoderPad. Todas estas herramientas tienen como objetivo constante cerrar la brecha entre una experiencia en línea y un mundo físico de ejecutar estos flujos de trabajo y hacer que la experiencia de colaboración sea lo más rica y fluida posible.

Para la mayoría de las herramientas de colaboración como estas, la capacidad de compartir opiniones entre sí y tener debates sobre el mismo contenido es imprescindible. Un sistema de comentarios que permite a los colaboradores anotar partes de un documento y tener conversaciones sobre ellos es el núcleo de este concepto. Además de crear uno para texto en un editor WYSIWYG, el artículo trata de involucrar a los lectores en cómo tratamos de sopesar los pros y los contras e intentamos encontrar un equilibrio entre la complejidad de la aplicación y la experiencia del usuario cuando se trata de crear funciones para editores WYSIWYG o Procesadores de texto en general.

Representación de comentarios en la estructura del documento

Para encontrar una forma de representar comentarios en la estructura de datos de un documento de texto enriquecido, veamos algunos escenarios en los que se pueden crear comentarios dentro de un editor.

  • Comentarios creados sobre texto que no tiene estilos (escenario básico);
  • Comentarios creados sobre texto que puede estar en negrita/cursiva/subrayado, etc.;
  • Comentarios que se superponen entre sí de alguna manera (superposición parcial donde dos comentarios comparten solo unas pocas palabras o contenidos completos donde el texto de un comentario está contenido completamente dentro del texto de otro comentario);
  • Comentarios creados sobre texto dentro de un enlace (especial porque los enlaces son nodos en sí mismos en la estructura de nuestro documento);
  • Comentarios que abarcan varios párrafos (especiales porque los párrafos son nodos en la estructura de nuestro documento y los comentarios se aplican a los nodos de texto que son los hijos de los párrafos).

Mirando los casos de uso anteriores, parece que los comentarios en la forma en que pueden aparecer en un documento de texto enriquecido son muy similares a los estilos de carácter (negrita, cursiva, etc.). Pueden superponerse entre sí, repasar el texto en otros tipos de nodos, como enlaces, e incluso abarcar varios nodos principales, como párrafos.

Por esta razón, usamos el mismo método para representar los comentarios que usamos para los estilos de carácter, es decir, "Marcas" (como se les llama en la terminología de SlateJS). Las marcas son solo propiedades regulares en los nodos; la especialidad es que la API de Slate alrededor de las marcas ( Editor.addMark y Editor.removeMark ) maneja el cambio de la jerarquía del nodo a medida que se aplican varias marcas al mismo rango de texto. Esto es extremadamente útil para nosotros, ya que tratamos con muchas combinaciones diferentes de comentarios superpuestos.

Comentar hilos como marcas

Cada vez que un usuario selecciona un rango de texto e intenta insertar un comentario, técnicamente, está iniciando un nuevo hilo de comentarios para ese rango de texto. Debido a que les permitiríamos insertar un comentario y luego responder a ese comentario, tratamos este evento como una nueva inserción de hilo de comentarios en el documento.

La forma en que representamos los hilos de comentarios como marcas es que cada hilo de comentarios está representado por una marca llamada commentThread_threadID donde threadID es una ID única que asignamos a cada hilo de comentarios. Por lo tanto, si el mismo rango de texto tiene dos hilos de comentarios sobre él, tendría dos propiedades establecidas en true : commentThread_thread1 y commentThread_thread2 . Aquí es donde los hilos de comentarios son muy similares a los estilos de caracteres, ya que si el mismo texto estuviera en negrita y cursiva, tendría ambas propiedades establecidas en true : bold y italic .

Antes de sumergirnos en la configuración real de esta estructura, vale la pena ver cómo cambian los nodos de texto a medida que se les aplican hilos de comentarios. La forma en que esto funciona (como lo hace con cualquier marca) es que cuando se establece una propiedad de marca en el texto seleccionado, la API Editor.addMark de Slate dividiría los nodos de texto si fuera necesario, de modo que en la estructura resultante, los nodos de texto se configuran de manera que cada nodo de texto tenga exactamente el mismo valor de la marca.

Para entender esto mejor, eche un vistazo a los siguientes tres ejemplos que muestran el estado anterior y posterior de los nodos de texto una vez que se inserta un hilo de comentarios en el texto seleccionado:

Ilustración que muestra cómo se divide el nodo de texto con una inserción de cadena de comentarios básica
Un nodo de texto que se divide en tres como una marca de hilo de comentario se inserta en el medio del texto. (Vista previa grande)
Ilustración que muestra cómo se divide el nodo de texto en caso de una superposición parcial de hilos de comentarios
Agregar un hilo de comentarios sobre 'el texto tiene' crea dos nuevos nodos de texto. (Vista previa grande)
Ilustración que muestra cómo se divide el nodo de texto en caso de una superposición parcial de hilos de comentarios con enlaces
Agregar un hilo de comentarios sobre 'tiene enlace' también divide el nodo de texto dentro del enlace. (Vista previa grande)
¡Más después del salto! Continúe leyendo a continuación ↓

Resaltar texto comentado

Ahora que sabemos cómo vamos a representar los comentarios en la estructura del documento, avancemos y agreguemos algunos al documento de ejemplo del primer artículo y configuremos el editor para que los muestre resaltados. Dado que tendremos muchas funciones de utilidad para manejar los comentarios en este artículo, creamos un módulo EditorCommentUtils que albergará todas estas utilidades. Para empezar, creamos una función que crea una marca para un ID de hilo de comentario determinado. Luego usamos eso para insertar algunos hilos de comentarios en nuestro ExampleDocument .

 # src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }

La imagen de abajo subraya en rojo los rangos de texto que tenemos como hilos de comentarios de ejemplo agregados en el siguiente fragmento de código. Tenga en cuenta que el texto 'Richard McClintock' tiene dos hilos de comentarios que se superponen entre sí. Específicamente, este es un caso en el que un hilo de comentarios está completamente contenido dentro de otro.

Imagen que muestra qué rangos de texto en el documento se comentarán, uno de ellos contenido completamente en otro.
Rangos de texto que se comentarían subrayados en rojo. (Vista previa grande)
 # src/utils/ExampleDocument.js import { getMarkForCommentThreadID } from "../utils/EditorCommentUtils"; import { v4 as uuid } from "uuid"; const exampleOverlappingCommentThreadID = uuid(); const ExampleDocument = [ ... { text: "Lorem ipsum", [getMarkForCommentThreadID(uuid())]: true, }, ... { text: "Richard McClintock", // note the two comment threads here. [getMarkForCommentThreadID(uuid())]: true, [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, { text: ", a Latin scholar", [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, ... ];

En este artículo nos enfocamos en el lado de la interfaz de usuario de un sistema de comentarios, por lo que les asignamos ID en el documento de ejemplo directamente usando el uuid del paquete npm. Es muy probable que en una versión de producción de un editor, estos ID sean creados por un servicio de backend.

Ahora nos enfocamos en ajustar el editor para mostrar estos nodos de texto como resaltados. Para hacer eso, al renderizar nodos de texto, necesitamos una forma de saber si tiene hilos de comentarios. Agregamos una getCommentThreadsOnTextNode para eso. Nos basamos en el componente StyledText que creamos en el primer artículo para manejar el caso en el que puede estar intentando representar un nodo de texto con comentarios. Dado que tenemos algunas funciones adicionales que se agregarán a los nodos de texto comentado más adelante, creamos un componente CommentedText que representa el texto comentado. StyledText verificará si el nodo de texto que intenta representar tiene algún comentario. Si lo hace, representa CommentedText . Utiliza una getCommentThreadsOnTextNode para deducir eso.

 # src/utils/EditorCommentUtils.js export function getCommentThreadsOnTextNode(textNode) { return new Set( // Because marks are just properties on nodes, // we can simply use Object.keys() here. Object.keys(textNode) .filter(isCommentThreadIDMark) .map(getCommentThreadIDFromMark) ); } export function getCommentThreadIDFromMark(mark) { if (!isCommentThreadIDMark(mark)) { throw new Error("Expected mark to be of a comment thread"); } return mark.replace(COMMENT_THREAD_PREFIX, ""); } function isCommentThreadIDMark(mayBeCommentThread) { return mayBeCommentThread.indexOf(COMMENT_THREAD_PREFIX) === 0; }

El primer artículo creó un componente StyledText que representa nodos de texto (manejando estilos de carácter, etc.). Extendemos ese componente para usar la utilidad anterior y representar un componente CommentedText si el nodo tiene comentarios.

 # src/components/StyledText.js import { getCommentThreadsOnTextNode } from "../utils/EditorCommentUtils"; export default function StyledText({ attributes, children, leaf }) { ... const commentThreads = getCommentThreadsOnTextNode(leaf); if (commentThreads.size > 0) { return ( <CommentedText {...attributes} // We use commentThreads and textNode props later in the article. commentThreads={commentThreads} textNode={leaf} > {children} </CommentedText> ); } return <span {...attributes}>{children}</span>; }

A continuación se muestra la implementación de CommentedText que representa el nodo de texto y adjunta el CSS que lo muestra resaltado.

 # src/components/CommentedText.js import "./CommentedText.css"; import classNames from "classnames"; export default function CommentedText(props) { const { commentThreads, ...otherProps } = props; return ( <span {...otherProps} className={classNames({ comment: true, })} > {props.children} </span> ); } # src/components/CommentedText.css .comment { background-color: #feeab5; }

Con todo el código anterior unido, ahora vemos nodos de texto con hilos de comentarios resaltados en el editor.

Los nodos de texto comentados aparecen resaltados después de que se han insertado hilos de comentarios
Los nodos de texto comentados aparecen resaltados después de que se hayan insertado los hilos de comentarios. (Vista previa grande)

Nota : los usuarios actualmente no pueden saber si cierto texto tiene comentarios superpuestos. Todo el rango de texto resaltado parece un solo hilo de comentarios. Abordamos eso más adelante en el artículo donde presentamos el concepto de hilo de comentarios activo que permite a los usuarios seleccionar un hilo de comentarios específico y poder ver su rango en el editor.

Almacenamiento de interfaz de usuario para comentarios

Antes de agregar la funcionalidad que permite a un usuario insertar nuevos comentarios, primero configuramos un estado de interfaz de usuario para contener nuestros hilos de comentarios. En este artículo, usamos RecoilJS como nuestra biblioteca de administración de estado para almacenar hilos de comentarios, comentarios contenidos dentro de los hilos y otros metadatos como la hora de creación, el estado, el autor del comentario, etc. Agreguemos Recoil a nuestra aplicación:

 > yarn add recoil

Usamos átomos de retroceso para almacenar estas dos estructuras de datos. Si no está familiarizado con Recoil, los átomos son los que mantienen el estado de la aplicación. Para diferentes partes del estado de la aplicación, generalmente querrá configurar diferentes átomos. Atom Family es una colección de átomos; se puede pensar que es un Map de una clave única que identifica el átomo con los átomos mismos. Vale la pena repasar los conceptos básicos de Recoil en este punto y familiarizarnos con ellos.

Para nuestro caso de uso, almacenamos hilos de comentarios como una familia Atom y luego envolvemos nuestra aplicación en un componente RecoilRoot . Se aplica RecoilRoot para proporcionar el contexto en el que se van a utilizar los valores atómicos. Creamos un módulo CommentState separado que contiene nuestras definiciones de átomos de Recoil a medida que agregamos más definiciones de átomos más adelante en el artículo.

 # src/utils/CommentState.js import { atom, atomFamily } from "recoil"; export const commentThreadsState = atomFamily({ key: "commentThreads", default: [], }); export const commentThreadIDsState = atom({ key: "commentThreadIDs", default: new Set([]), });

Vale la pena mencionar algunas cosas sobre estas definiciones de átomos:

  • Cada átomo/familia de átomos se identifica de forma única mediante una key y se puede configurar con un valor predeterminado.
  • A medida que avanzamos en este artículo, vamos a necesitar una forma de iterar sobre todos los hilos de comentarios, lo que básicamente significaría necesitar una forma de iterar sobre la familia de átomos commentThreadsState . Al momento de escribir este artículo, la forma de hacerlo con Recoil es configurar otro átomo que contenga todos los ID de la familia de átomos. Hacemos eso con commentThreadIDsState arriba. Ambos átomos tendrían que mantenerse sincronizados cada vez que agreguemos o eliminemos hilos de comentarios.

Agregamos un contenedor RecoilRoot en nuestro componente raíz de la App para que podamos usar estos átomos más adelante. La documentación de Recoil también proporciona un útil componente de depuración que tomamos tal como está y lo colocamos en nuestro editor. Este componente dejará los registros de console.debug en nuestra consola de desarrollo a medida que los átomos de Recoil se actualicen en tiempo real.

 # src/components/App.js import { RecoilRoot } from "recoil"; export default function App() { ... return ( <RecoilRoot> > ... <Editor document={document} onChange={updateDocument} /> </RecoilRoot> ); }
 # src/components/Editor.js export default function Editor({ ... }): JSX.Element { ..... return ( <> <Slate> ..... </Slate> <DebugObserver /> </> ); function DebugObserver(): React.Node { // see API link above for implementation. }

También necesitamos agregar código que inicialice nuestros átomos con los hilos de comentarios que ya existen en el documento (los que agregamos a nuestro documento de ejemplo en la sección anterior, por ejemplo). Lo hacemos en un momento posterior cuando construimos la barra lateral de comentarios que necesita leer todos los hilos de comentarios en un documento.

En este punto, cargamos nuestra aplicación, nos aseguramos de que no haya errores que apunten a nuestra configuración de Recoil y seguimos adelante.

Agregar nuevos comentarios

En esta sección, agregamos un botón a la barra de herramientas que le permite al usuario agregar comentarios (es decir, crear un nuevo hilo de comentarios) para el rango de texto seleccionado. Cuando el usuario selecciona un rango de texto y hace clic en este botón, debemos hacer lo siguiente:

  1. Asigne una ID única al nuevo hilo de comentarios que se está insertando.
  2. Agregue una nueva marca a la estructura del documento Slate con la ID para que el usuario vea ese texto resaltado.
  3. Agregue el nuevo hilo de comentarios a los átomos de retroceso que creamos en la sección anterior.

Agreguemos una función util a EditorCommentUtils que hace #1 y #2.

 # src/utils/EditorCommentUtils.js import { Editor } from "slate"; import { v4 as uuidv4 } from "uuid"; export function insertCommentThread(editor, addCommentThreadToState) { const threadID = uuidv4(); const newCommentThread = { // comments as added would be appended to the thread here. comments: [], creationTime: new Date(), // Newly created comment threads are OPEN. We deal with statuses // later in the article. status: "open", }; addCommentThreadToState(threadID, newCommentThread); Editor.addMark(editor, getMarkForCommentThreadID(threadID), true); return threadID; }

Al usar el concepto de marcas para almacenar cada hilo de comentarios como su propia marca, podemos simplemente usar la API Editor.addMark para agregar un nuevo hilo de comentarios en el rango de texto seleccionado. Esta llamada por sí sola maneja todos los diferentes casos de agregar comentarios, algunos de los cuales describimos en la sección anterior, comentarios parcialmente superpuestos, comentarios dentro/enlaces superpuestos, comentarios sobre texto en negrita/cursiva, comentarios que abarcan párrafos, etc. Esta llamada a la API ajusta la jerarquía de nodos para crear tantos nodos de texto nuevos como sea necesario para manejar estos casos.

addCommentThreadToState es una función de devolución de llamada que maneja el paso n. ° 3: agregar el nuevo hilo de comentarios a Recoil atom . Lo implementamos a continuación como un gancho de devolución de llamada personalizado para que sea reutilizable. Esta devolución de llamada debe agregar el nuevo hilo de comentarios a ambos átomos: commentThreadsState y commentThreadIDsState . Para poder hacer esto, usamos el gancho useRecoilCallback . Este enlace se puede usar para construir una devolución de llamada que obtiene algunas cosas que se pueden usar para leer/establecer datos atom. La que nos interesa ahora es la función set que se puede usar para actualizar un valor atómico como set(atom, newValueOrUpdaterFunction) .

 # src/hooks/useAddCommentThreadToState.js import { commentThreadIDsState, commentThreadsState, } from "../utils/CommentState"; import { useRecoilCallback } from "recoil"; export default function useAddCommentThreadToState() { return useRecoilCallback( ({ set }) => (id, threadData) => { set(commentThreadIDsState, (ids) => new Set([...Array.from(ids), id])); set(commentThreadsState(id), threadData); }, [] ); }

La primera llamada a set agrega la nueva ID al conjunto existente de ID de hilos de comentarios y devuelve el nuevo Set (que se convierte en el nuevo valor del átomo).

En la segunda llamada, obtenemos el átomo para el ID de la familia de átomos: commentThreadsState como commentThreadsState(id) y luego configuramos threadData para que sea su valor. atomFamilyName(atomID) es cómo Recoil nos permite acceder a un átomo de su familia de átomos usando la clave única. En términos generales, podríamos decir que si commentThreadsState fuera un mapa de JavaScript, esta llamada es básicamente: commentThreadsState.set(id, threadData) .

Ahora que tenemos todo este código configurado para manejar la inserción de un nuevo hilo de comentarios en el documento y los átomos de retroceso, agreguemos un botón a nuestra barra de herramientas y conéctelo con la llamada a estas funciones.

 # src/components/Toolbar.js import { insertCommentThread } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); ... const addCommentThread = useAddCommentThreadToState(); const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); }, [editor, addCommentThread]); return ( <div className="toolbar"> ... <ToolBarButton isActive={false} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> ); }

Nota : Usamos onMouseDown y no onClick , lo que habría hecho que el editor perdiera el foco y la selección se volviera null . Hemos discutido eso con un poco más de detalle en la sección de inserción de enlaces del primer artículo.

En el siguiente ejemplo, vemos la inserción en acción para un hilo de comentarios simple y un hilo de comentarios superpuestos con enlaces. Observe cómo recibimos actualizaciones de Recoil Debugger que confirman que nuestro estado se actualiza correctamente. También verificamos que se crean nuevos nodos de texto a medida que se agregan hilos al documento.

Insertar un hilo de comentarios divide el nodo de texto haciendo que el texto comentado sea su propio nodo.
Se crean más nodos de texto a medida que agregamos comentarios superpuestos.

Comentarios superpuestos

Antes de continuar agregando más funciones a nuestro sistema de comentarios, debemos tomar algunas decisiones sobre cómo vamos a tratar los comentarios superpuestos y sus diferentes combinaciones en el editor. Para ver por qué lo necesitamos, echemos un vistazo a cómo funciona una ventana emergente de comentarios, una funcionalidad que desarrollaremos más adelante en este artículo. Cuando un usuario hace clic en un texto determinado con hilos de comentarios, 'seleccionamos' un hilo de comentarios y mostramos una ventana emergente donde el usuario puede agregar comentarios a ese hilo.

Cuando el usuario hace clic en un nodo de texto con comentarios superpuestos, el editor debe decidir qué cadena de comentarios seleccionar.

Como puede ver en el video anterior, la palabra 'diseñadores' ahora forma parte de tres hilos de comentarios. Así que tenemos dos hilos de comentarios que se superponen entre sí sobre una palabra. Y ambos hilos de comentarios (n.º 1 y n.º 2) están completamente contenidos dentro de un rango de texto de hilo de comentarios más largo (n.º 3). Esto plantea algunas preguntas:

  1. ¿Qué hilo de comentarios deberíamos seleccionar y mostrar cuando el usuario hace clic en la palabra 'diseñadores'?
  2. Según cómo decidamos abordar la pregunta anterior, ¿tendríamos alguna vez un caso de superposición en el que al hacer clic en cualquier palabra nunca se activaría un determinado hilo de comentarios y no se podría acceder al hilo en absoluto?

Esto implica que, en el caso de comentarios superpuestos, lo más importante a considerar es: una vez que el usuario haya insertado un hilo de comentarios, ¿habría alguna forma de que puedan seleccionar ese hilo de comentarios en el futuro haciendo clic en algún texto dentro? ¿eso? Si no, probablemente no queramos permitirles que lo inserten en primer lugar. Para garantizar que este principio se respete la mayor parte del tiempo en nuestro editor, presentamos dos reglas con respecto a los comentarios superpuestos y las implementamos en nuestro editor.

Antes de definir esas reglas, vale la pena señalar que los diferentes editores y procesadores de texto tienen diferentes enfoques cuando se trata de comentarios superpuestos. Para simplificar las cosas, algunos editores no permiten ningún tipo de superposición de comentarios. En nuestro caso, tratamos de encontrar un término medio al no permitir casos demasiado complicados de superposiciones, pero aún así permitir comentarios superpuestos para que los usuarios puedan tener una experiencia de colaboración y revisión más rica.

Regla de rango de comentario más corto

Esta regla nos ayuda a responder la pregunta n.º 1 de arriba sobre qué hilo de comentarios seleccionar si un usuario hace clic en un nodo de texto que tiene varios hilos de comentarios. La regla es:

“Si el usuario hace clic en un texto que tiene múltiples hilos de comentarios, encontramos el hilo de comentarios del rango de texto más corto y lo seleccionamos”.

Intuitivamente, tiene sentido hacer esto para que el usuario siempre tenga una forma de llegar al hilo de comentarios más interno que está completamente contenido dentro de otro hilo de comentarios. Para otras condiciones (superposición parcial o no superposición), debe haber algún texto que tenga solo un hilo de comentarios, por lo que debería ser fácil usar ese texto para seleccionar ese hilo de comentarios. Es el caso de una superposición completa (o densa ) de subprocesos y por qué necesitamos esta regla.

Veamos un caso bastante complejo de superposición que nos permite usar esta regla y 'hacer lo correcto' al seleccionar el hilo de comentarios.

Ejemplo que muestra tres hilos de comentarios que se superponen entre sí de manera que la única forma de seleccionar un hilo de comentarios es usando la regla de longitud más corta.
Siguiendo la regla del hilo de comentarios más corto, al hacer clic en 'B' se selecciona el hilo de comentarios #1. (Vista previa grande)

En el ejemplo anterior, el usuario inserta los siguientes hilos de comentarios en ese orden:

  1. Comente el hilo n.º 1 sobre el carácter 'B' (longitud = 1).
  2. Tema de comentario #2 sobre 'AB' (longitud = 2).
  3. Tema de comentario n.º 3 sobre 'BC' (longitud = 2).

Al final de estas inserciones, debido a la forma en que Slate divide los nodos de texto con marcas, tendremos tres nodos de texto, uno para cada carácter. Ahora, si el usuario hace clic en 'B', siguiendo la regla de longitud más corta, seleccionamos el hilo #1 ya que es el más corto de los tres en longitud. Si no hacemos eso, no tendríamos una forma de seleccionar el hilo de comentarios n. ° 1, ya que solo tiene un carácter de longitud y también forma parte de otros dos hilos.

Aunque esta regla facilita la aparición de hilos de comentarios más cortos, podríamos encontrarnos con situaciones en las que los hilos de comentarios más largos se vuelvan inaccesibles, ya que todos los caracteres que contienen son parte de algún otro hilo de comentarios más corto. Veamos un ejemplo de eso.

Supongamos que tenemos 100 caracteres (por ejemplo, el carácter 'A' escrito 100 veces) y el usuario inserta hilos de comentarios en el siguiente orden:

  1. Comentar Hilo #1 de rango 20,80
  2. Comentar Hilo #2 de rango 0,50
  3. Comentario Hilo # 3 de rango 51,100
Ejemplo que muestra la regla de longitud más corta que hace que un hilo de comentarios no se pueda seleccionar, ya que todo su texto está cubierto por hilos de comentarios más cortos.
Todo el texto debajo del hilo de comentarios #1 también es parte de algún otro hilo de comentarios más corto que el #1. (Vista previa grande)

Como puede ver en el ejemplo anterior, si seguimos la regla que acabamos de describir aquí, al hacer clic en cualquier carácter entre el n.° 20 y el n.° 80, siempre se seleccionarán los hilos n.° 2 o n.° 3, ya que son más cortos que el n.° 1 y, por lo tanto, el n.° 1. no sería seleccionable. Otro escenario en el que esta regla puede dejarnos indecisos sobre qué hilo de comentarios seleccionar es cuando hay más de un hilo de comentarios de la misma longitud más corta en un nodo de texto.

Para tal combinación de comentarios superpuestos y muchas otras combinaciones similares en las que uno podría pensar en que seguir esta regla hace que cierto hilo de comentarios sea inaccesible al hacer clic en el texto, construimos una barra lateral de comentarios más adelante en este artículo que le da al usuario una vista de todos los hilos de comentarios. presentes en el documento para que puedan hacer clic en esos hilos en la barra lateral y activarlos en el editor para ver el rango del comentario. Todavía nos gustaría tener esta regla e implementarla, ya que debería cubrir muchos escenarios superpuestos, excepto los ejemplos menos probables que citamos anteriormente. Ponemos todo este esfuerzo en torno a esta regla principalmente porque ver el texto resaltado en el editor y hacer clic en él para comentar es una forma más intuitiva de acceder a un comentario en el texto que simplemente usar una lista de comentarios en la barra lateral.

Regla de inserción

La regla es:

"Si el texto que el usuario seleccionó y está tratando de comentar ya está completamente cubierto por hilos de comentarios, no permita esa inserción".

Esto es así porque si permitiéramos esta inserción, cada carácter en ese rango terminaría teniendo al menos dos hilos de comentarios (uno existente y otro nuevo que acabamos de permitir) lo que nos dificultaría determinar cuál seleccionar cuando el el usuario hace clic en ese carácter más tarde.

Mirando esta regla, uno podría preguntarse por qué la necesitamos en primer lugar si ya tenemos la regla de rango de comentario más corto que nos permite seleccionar el rango de texto más pequeño. ¿Por qué no permitir todas las combinaciones de superposiciones si podemos usar la primera regla para deducir el hilo de comentarios correcto para mostrar? Como algunos de los ejemplos que hemos discutido anteriormente, la primera regla funciona para muchos escenarios pero no para todos. Con la regla de inserción, tratamos de minimizar la cantidad de escenarios en los que la primera regla no puede ayudarnos y tenemos que recurrir a la barra lateral como la única forma en que el usuario puede acceder a ese hilo de comentarios. La regla de inserción también evita superposiciones exactas de hilos de comentarios. Esta regla es comúnmente implementada por muchos editores populares.

A continuación se muestra un ejemplo en el que, si esta regla no existiera, permitiríamos el hilo de comentarios n.° 3 y luego, como resultado de la primera regla, el n.° 3 no sería accesible, ya que se convertiría en el más largo.

Regla de inserción que no permite un tercer hilo de comentarios cuyo rango de texto completo está cubierto por otros dos hilos de comentarios.

Nota : tener esta regla no significa que nunca hubiéramos contenido completamente los comentarios superpuestos. Lo complicado de los comentarios superpuestos es que, a pesar de las reglas, el orden en que se insertan los comentarios puede dejarnos en un estado en el que no queríamos que se superpusieran. Volviendo a nuestro ejemplo de los comentarios sobre la palabra 'diseñadores ' anteriormente, el hilo de comentarios más largo insertado allí fue el último en agregarse, por lo que la regla de inserción lo permitiría y terminaríamos con una situación completamente contenida: #1 y #2 contenidos dentro de #3. Eso está bien porque la regla del rango de comentario más corto nos ayudaría.

Implementaremos la regla del rango de comentarios más corto en la siguiente sección, donde implementaremos la selección de hilos de comentarios. Dado que ahora tenemos un botón en la barra de herramientas para insertar comentarios, podemos implementar la regla de inserción de inmediato comprobando la regla cuando el usuario tiene algún texto seleccionado. Si la regla no se cumple, deshabilitaríamos el botón Comentar para que los usuarios no puedan insertar un nuevo hilo de comentarios en el texto seleccionado. ¡Empecemos!

 # src/utils/EditorCommentUtils.js export function shouldAllowNewCommentThreadAtSelection(editor, selection) { if (selection == null || Range.isCollapsed(selection)) { return false; } const textNodeIterator = Editor.nodes(editor, { at: selection, mode: "lowest", }); let nextTextNodeEntry = textNodeIterator.next().value; const textNodeEntriesInSelection = []; while (nextTextNodeEntry != null) { textNodeEntriesInSelection.push(nextTextNodeEntry); nextTextNodeEntry = textNodeIterator.next().value; } if (textNodeEntriesInSelection.length === 0) { return false; } return textNodeEntriesInSelection.some( ([textNode]) => getCommentThreadsOnTextNode(textNode).size === 0 ); }

La lógica en esta función es relativamente sencilla.

  • Si la selección del usuario es un signo de intercalación parpadeante, no permitimos insertar un comentario allí porque no se ha seleccionado ningún texto.
  • Si la selección del usuario no está colapsada, encontramos todos los nodos de texto en la selección. Tenga en cuenta el uso del mode: lowest en la llamada a Editor.nodes (una función auxiliar de SlateJS) que nos ayuda a seleccionar todos los nodos de texto, ya que los nodos de texto son realmente las hojas del árbol del documento.
  • Si hay al menos un nodo de texto que no tiene hilos de comentarios, podemos permitir la inserción. Usamos la utilidad getCommentThreadsOnTextNode que escribimos anteriormente aquí.

Ahora usamos esta función útil dentro de la barra de herramientas para controlar el estado deshabilitado del botón.

 # src/components/Toolbar.js export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); .... return ( <div className="toolbar"> .... <ToolBarButton isActive={false} disabled={!shouldAllowNewCommentThreadAtSelection( editor, selection )} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> );

Probemos la implementación de la regla recreando nuestro ejemplo anterior.

El botón de inserción en la barra de herramientas está deshabilitado cuando el usuario intenta insertar un comentario sobre un rango de texto que ya está completamente cubierto por otros comentarios.

Un buen detalle de la experiencia del usuario para resaltar aquí es que si bien deshabilitamos el botón de la barra de herramientas si el usuario ha seleccionado la línea completa de texto aquí, no completa la experiencia para el usuario. Es posible que el usuario no entienda completamente por qué el botón está deshabilitado y es probable que se confunda porque no estamos respondiendo a su intención de insertar un hilo de comentarios allí. Abordaremos esto más adelante, ya que las Ventanas emergentes de comentarios están diseñadas de tal manera que, incluso si el botón de la barra de herramientas está deshabilitado, aparecerá la ventana emergente para uno de los hilos de comentarios y el usuario aún podrá dejar comentarios.

Probemos también un caso en el que hay algún nodo de texto sin comentar y la regla permite insertar un nuevo hilo de comentarios.

Regla de inserción que permite la inserción de un hilo de comentarios cuando hay algún texto sin comentar dentro de la selección del usuario.

Selección de hilos de comentarios

En esta sección, habilitamos la función en la que el usuario hace clic en un nodo de texto comentado y usamos la regla de rango de comentario más corto para determinar qué cadena de comentarios debe seleccionarse. Los pasos en el proceso son:

  1. Encuentre el hilo de comentarios más corto en el nodo de texto comentado en el que hace clic el usuario.
  2. Configure ese hilo de comentarios para que sea el hilo de comentarios activo. (Creamos un nuevo átomo de Recoil que será la fuente de la verdad para esto).
  3. Los nodos de texto comentados escucharían el estado de retroceso y, si son parte del hilo de comentarios activo, se resaltarían de manera diferente. De esa forma, cuando el usuario hace clic en el hilo de comentarios, todo el rango de texto se destaca ya que todos los nodos de texto actualizarán su color de resaltado.

Paso 1: Implementación de la regla de rango de comentario más corto

Comencemos con el paso n.º 1, que básicamente implementa la regla del rango de comentarios más corto. El objetivo aquí es encontrar el hilo de comentarios del rango más corto en el nodo de texto en el que el usuario hizo clic. Para encontrar el hilo de menor longitud, necesitamos calcular la longitud de todos los hilos de comentarios en ese nodo de texto. Los pasos para hacer esto son:

  1. Obtenga todos los hilos de comentarios en el nodo de texto en cuestión.
  2. Atraviese en cualquier dirección desde ese nodo de texto y siga actualizando las longitudes de los hilos que se están rastreando.
  3. Detenga el recorrido en una dirección cuando hayamos llegado a uno de los siguientes bordes:
    • Un nodo de texto sin comentar (lo que implica que hemos alcanzado el borde inicial/final más alejado de todos los hilos de comentarios que estamos rastreando).
    • Un nodo de texto donde todos los hilos de comentarios que estamos rastreando han llegado a un borde (inicio/fin).
    • No hay más nodos de texto para atravesar en esa dirección (lo que implica que hemos llegado al principio o al final del documento o un nodo que no es de texto).

Dado que los recorridos en dirección hacia adelante y hacia atrás son funcionalmente iguales, vamos a escribir una función auxiliar updateCommentThreadLengthMap que básicamente toma un iterador de nodo de texto. Seguirá llamando al iterador y seguirá actualizando las longitudes de los subprocesos de seguimiento. Llamaremos a esta función dos veces, una para avanzar y otra para retroceder. Let's write our main utility function that will use this helper function.

 # src/utils/EditorCommentUtils.js export function getSmallestCommentThreadAtTextNode(editor, textNode) { const commentThreads = getCommentThreadsOnTextNode(textNode); const commentThreadsAsArray = [...commentThreads]; let shortestCommentThreadID = commentThreadsAsArray[0]; const reverseTextNodeIterator = (slateEditor, nodePath) => Editor.previous(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); const forwardTextNodeIterator = (slateEditor, nodePath) => Editor.next(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); if (commentThreads.size > 1) { // The map here tracks the lengths of the comment threads. // We initialize the lengths with length of current text node // since all the comment threads span over the current text node // at the least. const commentThreadsLengthByID = new Map( commentThreadsAsArray.map((id) => [id, textNode.text.length]) ); // traverse in the reverse direction and update the map updateCommentThreadLengthMap( editor, commentThreads, reverseTextNodeIterator, commentThreadsLengthByID ); // traverse in the forward direction and update the map updateCommentThreadLengthMap( editor, commentThreads, forwardTextNodeIterator, commentThreadsLengthByID ); let minLength = Number.POSITIVE_INFINITY; // Find the thread with the shortest length. for (let [threadID, length] of commentThreadsLengthByID) { if (length < minLength) { shortestCommentThreadID = threadID; minLength = length; } } } return shortestCommentThreadID; }

The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.

One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap such that it can call it while it is iterating text node's path and easily get the previous/next text node. To do that, Slate's traversal utilities Editor.previous and Editor.next (defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator and forwardTextNodeIterator call these helpers with two options mode: lowest and the match function Text.isText so we know we're getting a text node from the traversal, if there is one.

Now we implement updateCommentThreadLengthMap which traverses using these iterators and updates the lengths we're tracking.

 # src/utils/EditorCommentUtils.js function updateCommentThreadLengthMap( editor, commentThreads, nodeIterator, map ) { let nextNodeEntry = nodeIterator(editor); while (nextNodeEntry != null) { const nextNode = nextNodeEntry[0]; const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode); const intersection = [...commentThreadsOnNextNode].filter((x) => commentThreads.has(x) ); // All comment threads we're looking for have already ended meaning // reached an uncommented text node OR a commented text node which // has none of the comment threads we care about. if (intersection.length === 0) { break; } // update thread lengths for comment threads we did find on this // text node. for (let i = 0; i < intersection.length; i++) { map.set(intersection[i], map.get(intersection[i]) + nextNode.text.length); } // call the iterator to get the next text node to consider nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]); } return map; }

One might wonder why do we wait until the intersection becomes 0 to stop iterating in a certain direction. Why can't we just stop if we're reached the edge of at least one comment thread — that would imply we've reached the shortest length in that direction, right? The reason we can't do that is that we know that a comment thread can span over multiple text nodes and we wouldn't know which of those text nodes did the user click on and we started our traversal from. We wouldn't know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.

Check out the below example where we have two comment threads 'A' and 'B' overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.

Example of multiple comment threads overlapping on a text node.
Two comment threads overlapping over the word 'text'. (Vista previa grande)

In this example, let's assume we don't wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we'd stop at the start of text node #2 itself since that's the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.

To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.

Slideshow showing iterations in the implementation of Shortest Comment Thread Rule.

Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It

Now that we have the logic for the rule fully implemented, let's update the editor code to use it. For that, we first create a Recoil atom that'll store the active comment thread ID for us. We then update the CommentedText component to use our rule's implementation.

# src/utils/CommentState.js import { atom } from "recoil"; export const activeCommentThreadIDAtom = atom({ key: "activeCommentThreadID", default: null, }); # src/components/CommentedText.js import { activeCommentThreadIDAtom } from "../utils/CommentState"; import classNames from "classnames"; import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils"; import { useRecoilState } from "recoil"; export default function CommentedText(props) { .... const { commentThreads, textNode, ...otherProps } = props; const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = () => { setActiveCommentThreadID( getSmallestCommentThreadAtTextNode(editor, textNode) ); }; return ( <span {...otherProps} className={classNames({ comment: true, // a different background color treatment if this text node's // comment threads do contain the comment thread active on the // document right now. "is-active": commentThreads.has(activeCommentThreadID), })} onClick={onClick} > {props.children} ≷/span> ); }

Este componente usa useRecoilState que permite que un componente se suscriba y también pueda establecer el valor de Recoil atom. Necesitamos que el suscriptor sepa si este nodo de texto es parte del hilo de comentarios activo para que pueda tener un estilo diferente. Mire la captura de pantalla a continuación donde el hilo de comentarios en el medio está activo y podemos ver claramente su rango.

Ejemplo que muestra cómo saltan los nodos de texto debajo del hilo de comentarios seleccionado.
Los nodos de texto debajo del hilo de comentarios seleccionado cambian de estilo y saltan. (Vista previa grande)

Ahora que tenemos todo el código para hacer que funcione la selección de hilos de comentarios, veámoslo en acción. Para probar bien nuestro código transversal, probamos algunos casos sencillos de superposición y algunos casos extremos como:

  • Hacer clic en un nodo de texto comentado al inicio/final del editor.
  • Hacer clic en un nodo de texto comentado con hilos de comentarios que abarcan varios párrafos.
  • Hacer clic en un nodo de texto comentado justo antes de un nodo de imagen.
  • Al hacer clic en un nodo de texto comentado, se superponen enlaces.
Selección del hilo de comentario más corto para diferentes combinaciones de superposición.

Como ahora tenemos un átomo de Recoil para rastrear la identificación del hilo de comentarios activo, un pequeño detalle que se debe cuidar es configurar el hilo de comentarios recién creado para que sea el activo cuando el usuario usa el botón de la barra de herramientas para insertar un nuevo hilo de comentarios. Esto nos permite, en la siguiente sección, mostrar la ventana emergente del hilo de comentarios inmediatamente después de la inserción para que el usuario pueda comenzar a agregar comentarios de inmediato.

 # src/components/Toolbar.js import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; import { useSetRecoilState } from "recoil"; export default function Toolbar({ selection, previousSelection }) { ... const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); ..... const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); setActiveCommentThreadID(newCommentThreadID); }, [editor, addCommentThread, setActiveCommentThreadID]); return <div className='toolbar'> .... </div>; };

Nota: El uso de useSetRecoilState aquí (un gancho de retroceso que expone un setter para el átomo pero no suscribe el componente a su valor) es lo que necesitamos para la barra de herramientas en este caso.

Adición de popovers de hilos de comentarios

En esta sección, construimos una ventana emergente de comentarios que hace uso del concepto de hilo de comentarios seleccionado/activo y muestra una ventana emergente que permite al usuario agregar comentarios a ese hilo de comentarios. Antes de construirlo, echemos un vistazo rápido a cómo funciona.

Vista previa de la función Popover de comentarios.

Cuando intentamos mostrar una ventana emergente de comentarios cerca del hilo de comentarios que está activo, nos encontramos con algunos de los problemas que hicimos en el primer artículo con un menú del editor de enlaces. En este punto, se recomienda leer la sección del primer artículo que construye un Editor de enlaces y los problemas de selección que encontramos con eso.

Primero trabajemos en representar un componente popover vacío en el lugar correcto según el hilo de comentarios activo. La forma en que funcionaría popover es:

  • La ventana emergente de hilo de comentario se representa solo cuando hay un ID de hilo de comentario activo. Para obtener esa información, escuchamos el átomo de Recoil que creamos en la sección anterior.
  • Cuando se renderiza, encontramos el nodo de texto en la selección del editor y renderizamos el popover cerca de él.
  • Cuando el usuario hace clic en cualquier lugar fuera de la ventana emergente, establecemos que el hilo de comentarios activo sea null , lo que desactiva el hilo de comentarios y también hace que desaparezca la ventana emergente.
 # src/components/CommentThreadPopover.js import NodePopover from "./NodePopover"; import { getFirstTextNodeAtSelection } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; import { useSetRecoilState} from "recoil"; import {activeCommentThreadIDAtom} from "../utils/CommentState"; export default function CommentThreadPopover({ editorOffsets, selection, threadID }) { const editor = useEditor(); const textNode = getFirstTextNodeAtSelection(editor, selection); const setActiveCommentThreadID = useSetRecoilState( activeCommentThreadIDAtom ); const onClickOutside = useCallback( () => {}, [] ); return ( <NodePopover editorOffsets={editorOffsets} isBodyFullWidth={true} node={textNode} className={"comment-thread-popover"} onClickOutside={onClickOutside} > {`Comment Thread Popover for threadID:${threadID}`} </NodePopover> ); }

Un par de cosas que deberían mencionarse para esta implementación del componente popover:

  • Toma los editorOffsets y la selection del componente Editor donde se representaría. editorOffsets son los límites del componente Editor, por lo que podemos calcular la posición de la ventana emergente y la selection podría ser la selección actual o anterior en caso de que el usuario haya usado un botón de la barra de herramientas que provoque que la selection se vuelva null . La sección sobre el Editor de enlaces del primer artículo vinculado anteriormente los analiza en detalle.
  • Dado que LinkEditor del primer artículo y CommentThreadPopover aquí, ambos representan una ventana emergente alrededor de un nodo de texto, hemos movido esa lógica común a un componente NodePopover que maneja la representación del componente alineado con el nodo de texto en cuestión. Sus detalles de implementación son los que tenía el componente LinkEditor en el primer artículo.
  • NodePopover toma un método onClickOutside como accesorio que se llama si el usuario hace clic en algún lugar fuera del popover. Implementamos esto adjuntando el detector de eventos mousedown al document , como se explica en detalle en este artículo de Smashing sobre esta idea.
  • getFirstTextNodeAtSelection obtiene el primer nodo de texto dentro de la selección del usuario que usamos para mostrar la ventana emergente. La implementación de esta función utiliza los ayudantes de Slate para encontrar el nodo de texto.
 # src/utils/EditorUtils.js export function getFirstTextNodeAtSelection(editor, selection) { const selectionForNode = selection ?? editor.selection; if (selectionForNode == null) { return null; } const textNodeEntry = Editor.nodes(editor, { at: selectionForNode, mode: "lowest", match: Text.isText, }).next().value; return textNodeEntry != null ? textNodeEntry[0] : null; }

Implementemos la devolución de llamada onClickOutside que debería borrar el hilo de comentarios activo. Sin embargo, tenemos que tener en cuenta el escenario cuando la ventana emergente del hilo de comentarios está abierta y cierto hilo está activo y el usuario hace clic en otro hilo de comentarios. En ese caso, no queremos que onClickOutside restablezca el hilo de comentarios activo, ya que el evento de clic en el otro componente CommentedText debería configurar el otro hilo de comentarios para que se active. No queremos interferir con eso en el popover.

La forma en que lo hacemos es que encontramos el Slate Node más cercano al nodo DOM donde ocurrió el evento de clic. Si ese nodo Slate es un nodo de texto y tiene comentarios, omitimos restablecer el átomo de Recoil del hilo de comentarios activo. ¡Vamos a implementarlo!

 # src/components/CommentThreadPopover.js const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); const onClickOutside = useCallback( (event) => { const slateDOMNode = event.target.hasAttribute("data-slate-node") ? event.target : event.target.closest('[data-slate-node]'); // The click event was somewhere outside the Slate hierarchy. if (slateDOMNode == null) { setActiveCommentThreadID(null); return; } const slateNode = ReactEditor.toSlateNode(editor, slateDOMNode); // Click is on another commented text node => do nothing. if ( Text.isText(slateNode) && getCommentThreadsOnTextNode(slateNode).size > 0 ) { return; } setActiveCommentThreadID(null); }, [editor, setActiveCommentThreadID] );

Slate tiene un método auxiliar toSlateNode que devuelve el nodo Slate que se asigna a un nodo DOM o su ancestro más cercano si no es un nodo Slate. La implementación actual de este asistente arroja un error si no puede encontrar un nodo Slate en lugar de devolver null . Manejamos eso arriba comprobando el caso null nosotros mismos, que es un escenario muy probable si el usuario hace clic en algún lugar fuera del editor donde no existen los nodos Slate.

Ahora podemos actualizar el componente Editor para escuchar activeCommentThreadIDAtom y mostrar la ventana emergente solo cuando un hilo de comentarios está activo.

 # src/components/Editor.js import { useRecoilValue } from "recoil"; import { activeCommentThreadIDAtom } from "../utils/CommentState"; export default function Editor({ document, onChange }): JSX.Element { const activeCommentThreadID = useRecoilValue(activeCommentThreadIDAtom); // This hook is described in detail in the first article const [previousSelection, selection, setSelection] = useSelection(editor); return ( <> ... <div className="editor" ref={editorRef}> ... {activeCommentThreadID != null ? ( <CommentThreadPopover editorOffsets={editorOffsets} selection={selection ?? previousSelection} threadID={activeCommentThreadID} /> ) : null} </div> ... </> ); }

Verifiquemos que la ventana emergente se carga en el lugar correcto para el hilo de comentarios correcto y borra el hilo de comentarios activo cuando hacemos clic fuera.

La ventana emergente de la secuencia de comentarios se carga correctamente para la secuencia de comentarios seleccionada.

Ahora pasamos a permitir a los usuarios agregar comentarios a un hilo de comentarios y ver todos los comentarios de ese hilo en la ventana emergente. Vamos a utilizar la familia de átomos Recoil: commentThreadsState que creamos anteriormente en este artículo para esto.

Los comentarios en un hilo de comentarios se almacenan en la matriz de comments . Para habilitar la adición de un nuevo comentario, representamos una entrada de formulario que permite al usuario ingresar un nuevo comentario. Mientras el usuario escribe el comentario, lo mantenemos en una variable de estado local: commentText . Al hacer clic en el botón, agregamos el texto del comentario como el nuevo comentario a la matriz de comments .

 # src/components/CommentThreadPopover.js import { commentThreadsState } from "../utils/CommentState"; import { useRecoilState } from "recoil"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); const [commentText, setCommentText] = useState(""); const onClick = useCallback(() => { setCommentThreadData((threadData) => ({ ...threadData, comments: [ ...threadData.comments, // append comment to the comments on the thread. { text: commentText, author: "Jane Doe", creationTime: new Date() }, ], })); // clear the input setCommentText(""); }, [commentText, setCommentThreadData]); const onCommentTextChange = useCallback( (event) => setCommentText(event.target.value), [setCommentText] ); return ( <NodePopover ... > <div className={"comment-input-wrapper"}> <Form.Control bsPrefix={"comment-input form-control"} placeholder={"Type a comment"} type="text" value={commentText} onChange={onCommentTextChange} /> <Button size="sm" variant="primary" disabled={commentText.length === 0} onClick={onClick} > Comment </Button> </div> </NodePopover> ); }

Nota : Aunque representamos una entrada para que el usuario escriba un comentario, no necesariamente dejamos que tome el foco cuando se monta el popover. Esta es una decisión de Experiencia de Usuario que puede variar de un editor a otro. Algunos editores no permiten que los usuarios editen el texto mientras la ventana emergente del hilo de comentarios está abierta. En nuestro caso, queremos poder permitir que el usuario edite el texto comentado cuando haga clic en él.

Vale la pena mencionar cómo accedemos a los datos del hilo de comentarios específico de la familia de átomos Recoil, llamando al átomo como — commentThreadsState(threadID) . Esto nos da el valor del átomo y un setter para actualizar solo ese átomo en la familia. Si los comentarios se cargan de forma diferida desde el servidor, Recoil también proporciona un useRecoilStateLoadable que devuelve un objeto Loadable que nos informa sobre el estado de carga de los datos del átomo. Si aún se está cargando, podemos optar por mostrar un estado de carga en la ventana emergente.

Ahora, accedemos a threadData y renderizamos la lista de comentarios. Cada comentario es representado por el componente CommentRow .

 # src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );

A continuación se muestra la implementación de CommentRow que representa el texto del comentario y otros metadatos como el nombre del autor y la hora de creación. Usamos el módulo date-fns para mostrar una hora de creación formateada.

 # src/components/CommentRow.js import { format } from "date-fns"; export default function CommentRow({ comment: { author, text, creationTime }, }) { return ( <div className={"comment-row"}> <div className="comment-author-photo"> <i className="bi bi-person-circle comment-author-photo"></i> </div> <div> <span className="comment-author-name">{author}</span> <span className="comment-creation-time"> {format(creationTime, "eee MM/dd H:mm")} </span> <div className="comment-text">{text}</div> </div> </div> ); }

Hemos extraído esto para que sea su propio componente, ya que lo reutilizaremos más adelante cuando implementemos la barra lateral de comentarios.

En este punto, nuestro Comment Popover tiene todo el código que necesita para permitir insertar nuevos comentarios y actualizar el estado de Recoil para el mismo. Verifiquemos eso. En la consola del navegador, usando el Recoil Debug Observer que agregamos anteriormente, podemos verificar que el átomo de Recoil para el hilo de comentarios se actualiza correctamente a medida que agregamos nuevos comentarios al hilo.

La ventana emergente de hilo de comentario se carga al seleccionar un hilo de comentario.

Agregar una barra lateral de comentarios

Anteriormente en el artículo, mencionamos por qué, ocasionalmente, puede suceder que las reglas que implementamos impidan que un determinado hilo de comentarios no sea accesible al hacer clic solo en su(s) nodo(s) de texto, dependiendo de la combinación de superposición. Para tales casos, necesitamos una barra lateral de comentarios que le permita al usuario acceder a todos y cada uno de los hilos de comentarios en el documento.

Una barra lateral de comentarios también es una buena adición que se entrelaza con un flujo de trabajo de Sugerencias y revisión donde un revisor puede navegar a través de todos los hilos de comentarios uno tras otro en un barrido y puede dejar comentarios/respuestas donde lo sienta necesario. Antes de comenzar a implementar la barra lateral, hay una tarea pendiente de la que nos ocupamos a continuación.

Inicialización del estado de retroceso de hilos de comentarios

Cuando el documento se carga en el editor, necesitamos escanear el documento para encontrar todos los hilos de comentarios y agregarlos a los átomos de Recoil que creamos anteriormente como parte del proceso de inicialización. Escribamos una función de utilidad en EditorCommentUtils que escanee los nodos de texto, encuentre todos los hilos de comentarios y los agregue al átomo de Recoil.

 # src/utils/EditorCommentUtils.js export async function initializeStateWithAllCommentThreads( editor, addCommentThread ) { const textNodesWithComments = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).size > 0, }); const commentThreads = new Set(); let textNodeEntry = textNodesWithComments.next().value; while (textNodeEntry != null) { [...getCommentThreadsOnTextNode(textNodeEntry[0])].forEach((threadID) => { commentThreads.add(threadID); }); textNodeEntry = textNodesWithComments.next().value; } Array.from(commentThreads).forEach((id) => addCommentThread(id, { comments: [ { author: "Jane Doe", text: "Comment Thread Loaded from Server", creationTime: new Date(), }, ], status: "open", }) ); }

Sincronización con almacenamiento back-end y consideración de rendimiento

Para el contexto del artículo, dado que nos centramos exclusivamente en la implementación de la interfaz de usuario, simplemente los inicializamos con algunos datos que nos permiten confirmar que el código de inicialización funciona.

En el uso real del Sistema de comentarios, es probable que los hilos de comentarios se almacenen por separado del contenido del documento en sí. En tal caso, sería necesario actualizar el código anterior para realizar una llamada a la API que obtenga todos los metadatos y comentarios sobre todos los ID de hilos de comentarios en commentThreads . Una vez que se cargan los hilos de comentarios, es probable que se actualicen a medida que varios usuarios les agreguen más comentarios en tiempo real, cambien su estado, etc. La versión de producción del Sistema de comentarios necesitaría estructurar el almacenamiento de Recoil de manera que podamos seguir sincronizándolo con el servidor. Si elige usar Recoil para la administración del estado, hay algunos ejemplos en la API de Atom Effects (experimentales al momento de escribir este artículo) que hacen algo similar.

Si un documento es realmente largo y tiene muchos usuarios colaborando en él en muchos hilos de comentarios, es posible que tengamos que optimizar el código de inicialización para cargar solo hilos de comentarios para las primeras páginas del documento. Alternativamente, podemos optar por cargar solo los metadatos livianos de todos los hilos de comentarios en lugar de la lista completa de comentarios, que probablemente sea la parte más pesada de la carga útil.

Ahora, pasemos a llamar a esta función cuando el componente Editor se monte con el documento para que el estado de retroceso se inicialice correctamente.

 # src/components/Editor.js import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Editor({ document, onChange }): JSX.Element { ... const addCommentThread = useAddCommentThreadToState(); useEffect(() => { initializeStateWithAllCommentThreads(editor, addCommentThread); }, [editor, addCommentThread]); return ( <> ... </> ); }

Usamos el mismo enlace personalizado: useAddCommentThreadToState que usamos con la implementación del botón de comentarios de la barra de herramientas para agregar nuevos hilos de comentarios. Como tenemos el popover funcionando, podemos hacer clic en uno de los hilos de comentarios preexistentes en el documento y verificar que muestra los datos que usamos para inicializar el hilo anterior.

Al hacer clic en un hilo de comentarios preexistente, se carga correctamente la ventana emergente con sus comentarios.
Al hacer clic en un hilo de comentarios preexistente, se carga correctamente la ventana emergente con sus comentarios. (Vista previa grande)

Ahora que nuestro estado se inicializó correctamente, podemos comenzar a implementar la barra lateral. Todos nuestros hilos de comentarios en la interfaz de usuario se almacenan en la familia de átomos de Recoil: commentThreadsState . Como se destacó anteriormente, la forma en que iteramos a través de todos los elementos en una familia de átomos de Recoil es rastreando las claves/identificadores de átomos en otro átomo. Lo hemos estado haciendo con commentThreadIDsState . Agreguemos el componente CommentSidebar que itera a través del conjunto de ID en este átomo y genera un componente CommentThread para cada uno.

 # src/components/CommentsSidebar.js import "./CommentSidebar.css"; import {commentThreadIDsState,} from "../utils/CommentState"; import { useRecoilValue } from "recoil"; export default function CommentsSidebar(params) { const allCommentThreadIDs = useRecoilValue(commentThreadIDsState); return ( <Card className={"comments-sidebar"}> <Card.Header>Comments</Card.Header> <Card.Body> {Array.from(allCommentThreadIDs).map((id) => ( <Row key={id}> <Col> <CommentThread id={id} /> </Col> </Row> ))} </Card.Body> </Card> ); }

Ahora, implementamos el componente CommentThread que escucha el átomo de Recoil en la familia correspondiente al hilo de comentarios que está representando. De esta manera, a medida que el usuario agrega más comentarios en el hilo en el editor o cambia cualquier otro metadato, podemos actualizar la barra lateral para reflejar eso.

Como la barra lateral podría crecer hasta ser muy grande para un documento con muchos comentarios, ocultamos todos los comentarios excepto el primero cuando representamos la barra lateral. El usuario puede usar el botón 'Mostrar/Ocultar respuestas' para mostrar/ocultar todo el hilo de comentarios.

 # src/components/CommentSidebar.js function CommentThread({ id }) { const { comments } = useRecoilValue(commentThreadsState(id)); const [shouldShowReplies, setShouldShowReplies] = useState(false); const onBtnClick = useCallback(() => { setShouldShowReplies(!shouldShowReplies); }, [shouldShowReplies, setShouldShowReplies]); if (comments.length === 0) { return null; } const [firstComment, ...otherComments] = comments; return ( <Card body={true} className={classNames({ "comment-thread-container": true, })} > <CommentRow comment={firstComment} showConnector={false} /> {shouldShowReplies ? otherComments.map((comment, index) => ( <CommentRow key={`comment-${index}`} comment={comment} showConnector={true} /> )) : null} {comments.length > 1 ? ( <Button className={"show-replies-btn"} size="sm" variant="outline-primary" onClick={onBtnClick} > {shouldShowReplies ? "Hide Replies" : "Show Replies"} </Button> ) : null} </Card> ); }

Hemos reutilizado el componente CommentRow de la ventana emergente, aunque agregamos un tratamiento de diseño usando la propiedad showConnector que básicamente hace que todos los comentarios se vean conectados con un hilo en la barra lateral.

Ahora, renderizamos la CommentSidebar de comentarios en el Editor y verificamos que muestra todos los hilos que tenemos en el documento y se actualiza correctamente a medida que agregamos nuevos hilos o nuevos comentarios a los hilos existentes.

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Comentarios Barra lateral con todos los hilos de comentarios del documento.

Ahora pasamos a implementar una popular interacción de la barra lateral de comentarios que se encuentra en los editores:

Al hacer clic en un hilo de comentarios en la barra lateral, debe seleccionar/activar ese hilo de comentarios. También agregamos un tratamiento de diseño diferencial para resaltar un hilo de comentarios en la barra lateral si está activo en el editor. Para poder hacerlo, usamos el átomo Recoil — activeCommentThreadIDAtom . Actualicemos el componente CommentThread para admitir esto.

 # src/components/CommentsSidebar.js function CommentThread({ id }) { const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = useCallback(() => { setActiveCommentThreadID(id); }, [id, setActiveCommentThreadID]); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-active": activeCommentThreadID === id, })} onClick={onClick} > .... </Card> );
Al hacer clic en un hilo de comentarios en la barra lateral de comentarios, se selecciona en el editor y se resalta su rango.

Si miramos de cerca, tenemos un error en nuestra implementación de sincronizar el hilo de comentarios activo con la barra lateral. A medida que hacemos clic en diferentes hilos de comentarios en la barra lateral, el hilo de comentarios correcto se resalta en el editor. Sin embargo, la Ventana emergente de comentarios en realidad no se mueve al hilo de comentarios activo modificado. Permanece donde se renderizó por primera vez. Si observamos la implementación de Comment Popover, se representa contra el primer nodo de texto en la selección del editor. En ese punto de la implementación, la única forma de seleccionar un hilo de comentarios era hacer clic en un nodo de texto para que pudiéramos confiar convenientemente en la selección del editor, ya que Slate lo actualizó como resultado del evento de clic. En el evento onClick anterior, no actualizamos la selección, sino que simplemente actualizamos el valor del átomo de retroceso, lo que hace que la selección de Slate permanezca sin cambios y, por lo tanto, la ventana emergente de comentarios no se mueve.

Una solución a este problema es actualizar la selección del editor junto con la actualización del átomo de retroceso cuando el usuario hace clic en el hilo de comentarios en la barra lateral. Los pasos para hacer esto son:

  1. Encuentre todos los nodos de texto que tengan este hilo de comentarios que vamos a establecer como el nuevo hilo activo.
  2. Ordene estos nodos de texto en el orden en que aparecen en el documento (Usamos la API Path.compare de Slate para esto).
  3. Calcule un rango de selección que se extienda desde el inicio del primer nodo de texto hasta el final del último nodo de texto.
  4. Establezca el rango de selección para que sea la nueva selección del editor (usando la API Transforms.select de Slate).

Si solo quisiéramos corregir el error, podríamos encontrar el primer nodo de texto en el Paso 1 que tiene el hilo de comentarios y configurarlo para que sea la selección del editor. Sin embargo, se siente como un enfoque más limpio para seleccionar todo el rango de comentarios, ya que en realidad estamos seleccionando el hilo de comentarios.

Actualicemos la implementación de devolución de llamada onClick para incluir los pasos anteriores.

 const onClick = useCallback(() => { const textNodesWithThread = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).has(id), }); let textNodeEntry = textNodesWithThread.next().value; const allTextNodePaths = []; while (textNodeEntry != null) { allTextNodePaths.push(textNodeEntry[1]); textNodeEntry = textNodesWithThread.next().value; } // sort the text nodes allTextNodePaths.sort((p1, p2) => Path.compare(p1, p2)); // set the selection on the editor Transforms.select(editor, { anchor: Editor.point(editor, allTextNodePaths[0], { edge: "start" }), focus: Editor.point( editor, allTextNodePaths[allTextNodePaths.length - 1], { edge: "end" } ), }); // Update the Recoil atom value. setActiveCommentThreadID(id); }, [editor, id, setActiveCommentThreadID]);

Nota : allTextNodePaths contiene la ruta a todos los nodos de texto. Usamos la API Editor.point para obtener los puntos de inicio y final en esa ruta. El primer artículo repasa los conceptos de ubicación de Slate. También están bien documentados en la documentación de Slate.

Verifiquemos que esta implementación solucione el error y que la ventana emergente de comentarios se mueva al hilo de comentarios activo correctamente. Esta vez, también probamos con un caso de hilos superpuestos para asegurarnos de que no se rompa allí.

Al hacer clic en una secuencia de comentarios en la barra lateral de comentarios, se selecciona y se carga la ventana emergente de la secuencia de comentarios.

Con la corrección del error, habilitamos otra interacción de la barra lateral que aún no hemos discutido. Si tenemos un documento realmente largo y el usuario hace clic en un hilo de comentarios en la barra lateral que está fuera de la ventana gráfica, nos gustaría desplazarnos a esa parte del documento para que el usuario pueda concentrarse en el hilo de comentarios en el editor. Al configurar la selección anterior con la API de Slate, la obtenemos de forma gratuita. Veámoslo en acción a continuación.

El documento se desplaza correctamente al hilo de comentarios cuando se hace clic en la barra lateral de comentarios.

Con eso, envolvemos nuestra implementación de la barra lateral. Hacia el final del artículo, enumeramos algunas funciones adicionales y mejoras que podemos hacer en la barra lateral de comentarios que ayudan a mejorar la experiencia de comentar y revisar en el editor.

Resolver y reabrir comentarios

En esta sección, nos enfocamos en permitir que los usuarios marquen hilos de comentarios como 'Resueltos' o puedan volver a abrirlos para discusión si es necesario. Desde la perspectiva de los detalles de implementación, estos son los metadatos de status en un hilo de comentarios que cambiamos cuando el usuario realiza esta acción. Desde la perspectiva del usuario, esta es una función muy útil, ya que les brinda una forma de afirmar que la discusión sobre algo en el documento ha concluido o debe reabrirse porque hay algunas actualizaciones/nuevas perspectivas, etc.

Para permitir alternar el estado, agregamos un botón a CommentPopover que permite al usuario alternar entre los dos estados: open y resolved .

 # src/components/CommentThreadPopover.js export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { … const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); ... const onToggleStatus = useCallback(() => { const currentStatus = threadData.status; setCommentThreadData((threadData) => ({ ...threadData, status: currentStatus === "open" ? "resolved" : "open", })); }, [setCommentThreadData, threadData.status]); return ( <NodePopover ... header={ <Header status={threadData.status} shouldAllowStatusChange={threadData.comments.length > 0} onToggleStatus={onToggleStatus} /> } > <div className={"comment-list"}> ... </div> </NodePopover> ); } function Header({ onToggleStatus, shouldAllowStatusChange, status }) { return ( <div className={"comment-thread-popover-header"}> {shouldAllowStatusChange && status != null ? ( <Button size="sm" variant="primary" onClick={onToggleStatus}> {status === "open" ? "Resolve" : "Re-Open"} </Button> ) : null} </div> ); }

Antes de probar esto, también demos a la barra lateral de comentarios un tratamiento de diseño diferencial para los comentarios resueltos para que el usuario pueda detectar fácilmente qué hilos de comentarios no están resueltos o están abiertos y enfocarse en ellos si así lo desean.

 # src/components/CommentsSidebar.js function CommentThread({ id }) { ... const { comments, status } = useRecoilValue(commentThreadsState(id)); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-resolved": status === "resolved", "is-active": activeCommentThreadID === id, })} onClick={onClick} > ... </Card> ); }
El estado de la secuencia de comentarios se alterna desde la ventana emergente y se refleja en la barra lateral.

Conclusión

En este artículo, construimos la infraestructura central de la interfaz de usuario para un sistema de comentarios en un editor de texto enriquecido. El conjunto de funcionalidades que agregamos aquí actúa como base para construir una experiencia de colaboración más rica en un editor donde los colaboradores pueden anotar partes del documento y tener conversaciones sobre ellas. Agregar una barra lateral de comentarios nos brinda un espacio para tener más funcionalidades conversacionales o basadas en revisiones para habilitarlas en el producto.

En ese sentido, estas son algunas de las características que un editor de texto enriquecido podría considerar agregar además de lo que creamos en este artículo:

  • Compatibilidad con menciones @ para que los colaboradores puedan etiquetarse entre sí en los comentarios;
  • Soporte para tipos de medios como imágenes y videos que se agregarán a los hilos de comentarios;
  • Modo de sugerencia a nivel de documento que permite a los revisores realizar ediciones en el documento que aparecen como sugerencias de cambios. Se podría hacer referencia a esta función en Google Docs o Change Tracking en Microsoft Word como ejemplos;
  • Mejoras en la barra lateral para buscar conversaciones por palabra clave, filtrar hilos por estado o autor(es) de comentarios, etc.