Adicionando um sistema de comentários a um editor WYSIWYG
Publicados: 2022-03-10Nos últimos anos, vimos a colaboração penetrar em muitos fluxos de trabalho digitais e casos de uso em muitas profissões. Apenas dentro da comunidade de Design e Engenharia de Software, vemos designers colaborando em artefatos de design usando ferramentas como Figma, equipes fazendo Sprint e Planejamento de Projetos usando ferramentas como Mural e entrevistas sendo conduzidas usando CoderPad. Todas essas ferramentas visam constantemente preencher a lacuna entre uma experiência online e uma experiência no mundo físico de executar esses fluxos de trabalho e tornar a experiência de colaboração a mais rica e perfeita possível.
Para a maioria das Ferramentas de Colaboração como essas, a capacidade de compartilhar opiniões entre si e discutir o mesmo conteúdo é essencial. Um sistema de comentários que permite aos colaboradores anotar partes de um documento e conversar sobre elas está no centro desse conceito. Junto com a construção de um para texto em um Editor WYSIWYG, o artigo tenta envolver os leitores em como tentamos pesar os prós e contras e tentamos encontrar um equilíbrio entre a complexidade do aplicativo e a experiência do usuário quando se trata de criar recursos para Editores WYSIWYG ou Processadores de texto em geral.
Representando comentários na estrutura do documento
Para encontrar uma maneira de representar comentários na estrutura de dados de um documento rich text, vejamos alguns cenários em que comentários podem ser criados dentro de um editor.
- Comentários criados sobre texto sem estilos (cenário básico);
- Comentários criados sobre texto que pode estar em negrito/itálico/sublinhado e assim por diante;
- Comentários que se sobrepõem de alguma forma (sobreposição parcial onde dois comentários compartilham apenas algumas palavras ou totalmente contidos quando o texto de um comentário está totalmente contido no texto de outro comentário);
- Comentários criados sobre texto dentro de um link (especial porque os links são nós em nossa estrutura de documento);
- Comentários que abrangem vários parágrafos (especial porque os parágrafos são nós em nossa estrutura de documento e os comentários são aplicados a nós de texto que são filhos do parágrafo).
Olhando para os casos de uso acima, parece que os comentários na forma como eles podem aparecer em um documento de rich text são muito semelhantes aos estilos de caracteres (negrito, itálico, etc). Eles podem se sobrepor, passar por cima do texto em outros tipos de nós, como links, e até mesmo abranger vários nós pais, como parágrafos.
Por esta razão, usamos o mesmo método para representar comentários como fazemos para estilos de caracteres, ou seja, “Marcas” (como são chamados na terminologia SlateJS). Marcas são apenas propriedades regulares em nós - a especialidade é que a API do Slate em torno de marcas ( Editor.addMark
e Editor.removeMark
) lida com a mudança da hierarquia de nós à medida que várias marcas são aplicadas ao mesmo intervalo de texto. Isso é extremamente útil para nós, pois lidamos com muitas combinações diferentes de comentários sobrepostos.
Tópicos de comentários como marcas
Sempre que um usuário seleciona um intervalo de texto e tenta inserir um comentário, tecnicamente, ele está iniciando um novo encadeamento de comentários para esse intervalo de texto. Como permitimos que eles insiram um comentário e depois respondam a esse comentário, tratamos esse evento como uma nova inserção de thread de comentários no documento.
A maneira como representamos threads de comentários como marcas é que cada thread de comentário é representado por uma marca chamada commentThread_threadID
, onde threadID
é um ID exclusivo que atribuímos a cada thread de comentário. Portanto, se o mesmo intervalo de texto tiver dois threads de comentários sobre ele, ele terá duas propriedades definidas como true
— commentThread_thread1
e commentThread_thread2
. É aqui que os tópicos de comentários são muito semelhantes aos estilos de caracteres, pois se o mesmo texto estivesse em negrito e itálico, ele teria as duas propriedades definidas como true
— bold
e italic
.
Antes de nos aprofundarmos na configuração dessa estrutura, vale a pena observar como os nós de texto mudam à medida que os tópicos de comentários são aplicados a eles. A maneira como isso funciona (como acontece com qualquer marca) é que, quando uma propriedade de marca está sendo definida no texto selecionado, a API Editor.addMark do Slate dividiria o(s) nó(s) de texto, se necessário, de modo que, na estrutura resultante, os nós de texto são configurados de forma que cada nó de texto tenha exatamente o mesmo valor da marca.
Para entender isso melhor, dê uma olhada nos três exemplos a seguir que mostram o estado antes e depois dos nós de texto quando um tópico de comentários é inserido no texto selecionado:



Destacando o texto comentado
Agora que sabemos como vamos representar os comentários na estrutura do documento, vamos adicionar alguns ao documento de exemplo do primeiro artigo e configurar o editor para realmente mostrá-los como destacados. Como teremos muitas funções utilitárias para lidar com comentários neste artigo, criamos um módulo EditorCommentUtils
que abrigará todos esses utilitários. Para começar, criamos uma função que cria uma marca para um determinado ID de thread de comentário. Em seguida, usamos isso para inserir alguns tópicos de comentários em nosso ExampleDocument
.
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
A imagem abaixo sublinha em vermelho os intervalos de texto que temos como exemplos de tópicos de comentários adicionados no próximo trecho de código. Observe que o texto 'Richard McClintock' tem dois tópicos de comentários que se sobrepõem. Especificamente, este é o caso de um segmento de comentário totalmente contido dentro de outro.

# 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, }, ... ];
Nós nos concentramos no lado da interface do usuário de um sistema de comentários neste artigo, portanto, atribuímos IDs a eles no documento de exemplo diretamente usando o pacote npm uuid. Muito provavelmente, em uma versão de produção de um editor, esses IDs são criados por um serviço de back-end.
Agora nos concentramos em ajustar o editor para mostrar esses nós de texto como destacados. Para fazer isso, ao renderizar nós de texto, precisamos de uma maneira de saber se há threads de comentários nele. Adicionamos um util getCommentThreadsOnTextNode
para isso. Construímos no componente StyledText
que criamos no primeiro artigo para lidar com o caso em que ele pode estar tentando renderizar um nó de texto com comentários. Como temos mais algumas funcionalidades que seriam adicionadas aos nós de texto comentado posteriormente, criamos um componente CommentedText
que renderiza o texto comentado. StyledText
verificará se o nó de texto que está tentando renderizar possui algum comentário. Se isso acontecer, ele renderiza CommentedText
. Ele usa um getCommentThreadsOnTextNode
para deduzir isso.
# 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; }
O primeiro artigo construiu um componente StyledText
que renderiza nós de texto (manipulação de estilos de caracteres e assim por diante). Estendemos esse componente para usar o utilitário acima e renderizar um componente CommentedText
se o nó tiver comentários sobre ele.
# 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>; }
Abaixo está a implementação de CommentedText
que renderiza o nó de texto e anexa o CSS que o mostra como destacado.
# 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; }
Com todo o código acima reunido, agora vemos nós de texto com tópicos de comentários destacados no editor.

Nota : Os usuários atualmente não podem dizer se determinado texto tem comentários sobrepostos. Todo o intervalo de texto realçado se parece com um único tópico de comentários. Abordamos isso mais adiante no artigo, onde apresentamos o conceito de thread de comentários ativo, que permite aos usuários selecionar um thread de comentários específico e poder ver seu intervalo no editor.
Armazenamento de IU para comentários
Antes de adicionarmos a funcionalidade que permite que um usuário insira novos comentários, primeiro configuramos um estado de interface do usuário para manter nossos encadeamentos de comentários. Neste artigo, usamos o RecoilJS como nossa biblioteca de gerenciamento de estado para armazenar threads de comentários, comentários contidos nos threads e outros metadados como tempo de criação, status, autor do comentário etc. Vamos adicionar o Recoil ao nosso aplicativo:
> yarn add recoil
Usamos átomos Recoil para armazenar essas duas estruturas de dados. Se você não estiver familiarizado com o Recoil, os átomos são o que mantém o estado do aplicativo. Para diferentes partes do estado do aplicativo, você geralmente deseja configurar átomos diferentes. Atom Family é uma coleção de átomos - pode ser pensado como um Map
de uma chave única que identifica o átomo para os próprios átomos. Vale a pena passar pelos principais conceitos do Recoil neste momento e nos familiarizar com eles.
Para nosso caso de uso, armazenamos threads de comentários como uma família Atom e, em seguida, agrupamos nosso aplicativo em um componente RecoilRoot
. RecoilRoot
é aplicado para fornecer o contexto no qual os valores do átomo serão usados. Criamos um módulo separado CommentState
que contém nossas definições de átomo Recoil à medida que adicionamos mais definições de átomo posteriormente no artigo.
# 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 a pena destacar algumas coisas sobre essas definições de átomos:
- Cada átomo/família de átomos é identificada exclusivamente por uma
key
e pode ser configurada com um valor padrão. - À medida que avançamos neste artigo, precisaremos de uma maneira de iterar sobre todos os threads de comentários, o que basicamente significaria precisar de uma maneira de iterar sobre a família de átomos
commentThreadsState
. No momento em que escrevo este artigo, a maneira de fazer isso com o Recoil é configurar outro átomo que contenha todos os IDs da família de átomos. Fazemos isso comcommentThreadIDsState
acima. Ambos os átomos teriam que ser mantidos em sincronia sempre que adicionamos/excluímos tópicos de comentários.
Adicionamos um wrapper RecoilRoot
em nosso componente App
raiz para que possamos usar esses átomos posteriormente. A documentação do Recoil também fornece um útil componente Debugger que pegamos como está e colocamos em nosso editor. Este componente deixará os logs console.debug
para nosso console de desenvolvimento, pois os átomos do Recoil são atualizados em tempo 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. }
Também precisamos adicionar código que inicialize nossos átomos com os threads de comentários que já existem no documento (os que adicionamos ao nosso documento de exemplo na seção anterior, por exemplo). Fazemos isso posteriormente, quando criamos a Barra Lateral de Comentários que precisa ler todos os tópicos de comentários em um documento.
Neste ponto, carregamos nosso aplicativo, verificamos se não há erros apontando para a configuração do Recoil e seguimos em frente.
Adicionando novos comentários
Nesta seção, adicionamos um botão à barra de ferramentas que permite ao usuário adicionar comentários (ou seja, criar um novo tópico de comentários) para o intervalo de texto selecionado. Quando o usuário seleciona um intervalo de texto e clica neste botão, precisamos fazer o seguinte:
- Atribua um ID exclusivo ao novo tópico de comentários que está sendo inserido.
- Adicione uma nova marca à estrutura do documento Slate com o ID para que o usuário veja esse texto realçado.
- Adicione o novo tópico de comentários aos átomos de recuo que criamos na seção anterior.
Vamos adicionar uma função util ao EditorCommentUtils
que faz #1 e #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; }
Ao usar o conceito de marcas para armazenar cada thread de comentário como sua própria marca, podemos simplesmente usar a API Editor.addMark
para adicionar um novo thread de comentário no intervalo de texto selecionado. Essa chamada sozinha lida com todos os diferentes casos de adição de comentários — alguns dos quais descrevemos na seção anterior — comentários parcialmente sobrepostos, comentários dentro/links sobrepostos, comentários sobre texto em negrito/itálico, comentários abrangendo parágrafos e assim por diante. Essa chamada de API ajusta a hierarquia de nós para criar quantos novos nós de texto forem necessários para lidar com esses casos.
addCommentThreadToState
é uma função de retorno de chamada que lida com a etapa 3 — adicionar o novo thread de comentários ao Recoil atom . Implementamos isso a seguir como um gancho de retorno de chamada personalizado para que seja reutilizável. Esse retorno de chamada precisa adicionar o novo thread de comentários a ambos os átomos — commentThreadsState
e commentThreadIDsState
. Para poder fazer isso, usamos o gancho useRecoilCallback
. Este gancho pode ser usado para construir um retorno de chamada que obtém algumas coisas que podem ser usadas para ler/definir dados do átomo. O que estamos interessados agora é a função set
que pode ser usada para atualizar um valor de átomo 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); }, [] ); }
A primeira chamada para set
adiciona o novo ID ao conjunto existente de IDs de thread de comentários e retorna o novo Set
(que se torna o novo valor do átomo).
Na segunda chamada, obtemos o átomo para o ID da família de átomos — commentThreadsState
como commentThreadsState(id)
e, em seguida, definimos o threadData
como seu valor. atomFamilyName(atomID)
é como o Recoil nos permite acessar um átomo de sua família de átomos usando a chave exclusiva. Falando livremente, poderíamos dizer que se commentThreadsState
fosse um Map javascript, essa chamada seria basicamente — commentThreadsState.set(id, threadData)
.
Agora que temos todo esse código configurado para lidar com a inserção de um novo thread de comentários no documento e átomos de Recoil, vamos adicionar um botão à nossa barra de ferramentas e conectá-lo com a chamada para essas funções.
# 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
e não onClick
, o que faria o editor perder o foco e a seleção se tornar null
. Discutimos isso com um pouco mais de detalhes na seção de inserção de link do primeiro artigo.
No exemplo abaixo, vemos a inserção em ação para um thread de comentários simples e um thread de comentários sobrepostos com links. Observe como obtemos atualizações do Recoil Debugger confirmando que nosso estado está sendo atualizado corretamente. Também verificamos se novos nós de texto são criados à medida que os segmentos são adicionados ao documento.
Comentários sobrepostos
Antes de continuarmos adicionando mais recursos ao nosso sistema de comentários, precisamos tomar algumas decisões sobre como vamos lidar com comentários sobrepostos e suas diferentes combinações no editor. Para ver por que precisamos disso, vamos dar uma olhada em como funciona um popover de comentário — uma funcionalidade que construiremos mais adiante neste artigo. Quando um usuário clica em um determinado texto com tópicos de comentários nele, nós 'selecionamos' um tópico de comentários e mostramos um popover onde o usuário pode adicionar comentários a esse tópico.
Como você pode ver no vídeo acima, a palavra 'designers' agora faz parte de três tópicos de comentários. Portanto, temos dois tópicos de comentários que se sobrepõem em uma palavra. E ambos os tópicos de comentários (#1 e #2) estão totalmente contidos dentro de um intervalo de texto de tópicos de comentários mais longo (#3). Isso levanta algumas questões:
- Qual thread de comentários devemos selecionar e mostrar quando o usuário clicar na palavra 'designers'?
- Com base em como decidimos resolver a questão acima, teríamos um caso de sobreposição em que clicar em qualquer palavra nunca ativaria um determinado tópico de comentários e o tópico não poderia ser acessado?
Isso implica que, no caso de comentários sobrepostos, a coisa mais importante a considerar é - uma vez que o usuário tenha inserido um tópico de comentários, haveria uma maneira de selecionar esse tópico de comentários no futuro clicando em algum texto dentro isto? Caso contrário, provavelmente não queremos permitir que eles o insiram em primeiro lugar. Para garantir que esse princípio seja respeitado na maioria das vezes em nosso editor, introduzimos duas regras sobre sobreposição de comentários e as implementamos em nosso editor.
Antes de definirmos essas regras, vale ressaltar que diferentes editores e processadores de texto têm abordagens diferentes quando se trata de comentários sobrepostos. Para simplificar, alguns editores não permitem comentários sobrepostos. Em nosso caso, tentamos encontrar um meio-termo não permitindo casos muito complicados de sobreposições, mas ainda permitindo comentários sobrepostos para que os usuários possam ter uma experiência de Colaboração e Revisão mais rica.
Regra do intervalo de comentários mais curto
Essa regra nos ajuda a responder à pergunta nº 1 acima sobre qual thread de comentários selecionar se um usuário clicar em um nó de texto que tenha vários threads de comentários nele. A regra é:
“Se o usuário clicar em um texto que tenha vários tópicos de comentários, encontramos o tópico de comentários do intervalo de texto mais curto e o selecionamos.”
Intuitivamente, faz sentido fazer isso para que o usuário sempre tenha uma maneira de acessar o segmento de comentário mais interno que está totalmente contido em outro segmento de comentário. Para outras condições (sobreposição parcial ou sem sobreposição), deve haver algum texto que tenha apenas um tópico de comentário, portanto, deve ser fácil usar esse texto para selecionar esse tópico de comentário. É o caso de uma sobreposição completa (ou densa ) de threads e por que precisamos dessa regra.
Vejamos um caso bastante complexo de sobreposição que nos permite usar essa regra e 'fazer a coisa certa' ao selecionar o tópico de comentários.

No exemplo acima, o usuário insere os seguintes tópicos de comentários nessa ordem:
- Comente Thread #1 sobre o caractere 'B' (comprimento = 1).
- Comentário Thread #2 sobre 'AB' (comprimento = 2).
- Comente Thread #3 sobre 'BC' (comprimento = 2).
Ao final dessas inserções, devido à forma como o Slate divide os nós de texto com marcas, teremos três nós de texto — um para cada caractere. Agora, se o usuário clicar em 'B', seguindo a regra do menor comprimento, selecionamos o thread #1, pois é o menor dos três em comprimento. Se não fizermos isso, não teríamos como selecionar o tópico de comentários #1, pois ele tem apenas um caractere e também faz parte de dois outros tópicos.
Embora essa regra facilite a exibição de encadeamentos de comentários de comprimento mais curto, podemos nos deparar com situações em que encadeamentos de comentários mais longos se tornam inacessíveis, pois todos os caracteres contidos neles fazem parte de algum outro encadeamento de comentários mais curto. Vejamos um exemplo para isso.
Vamos supor que temos 100 caracteres (digamos, caractere 'A' digitado 100 vezes) e o usuário insere tópicos de comentários na seguinte ordem:
- Comentário Tópico nº 1 do intervalo 20,80
- Comentário Tópico nº 2 do intervalo 0,50
- Comentário Tópico nº 3 do intervalo 51.100

Como você pode ver no exemplo acima, se seguirmos a regra que acabamos de descrever aqui, clicar em qualquer caractere entre #20 e #80, sempre selecionaria os tópicos #2 ou #3, pois são mais curtos que #1 e, portanto, #1 não seria selecionável. Outro cenário em que essa regra pode nos deixar indecisos sobre qual thread de comentários selecionar é quando há mais de um thread de comentários do mesmo tamanho mais curto em um nó de texto.
Para essa combinação de comentários sobrepostos e muitas outras combinações em que se poderia pensar em seguir essa regra torna um determinado tópico de comentários inacessível clicando no texto, criamos uma barra lateral de comentários mais adiante neste artigo, que oferece ao usuário uma visão de todos os tópicos de comentários presentes no documento para que possam clicar nesses tópicos na barra lateral e ativá-los no editor para ver o alcance do comentário. Ainda gostaríamos de ter essa regra e implementá-la, pois ela deve abranger muitos cenários de sobreposição, exceto os exemplos menos prováveis que citamos acima. Colocamos todo esse esforço em torno dessa regra principalmente porque ver o texto destacado no editor e clicar nele para comentar é uma maneira mais intuitiva de acessar um comentário no texto do que simplesmente usar uma lista de comentários na barra lateral.
Regra de inserção
A regra é:
“Se o usuário de texto selecionou e está tentando comentar já está totalmente coberto por tópicos de comentários, não permita essa inserção.”
Isso porque, se permitíssemos essa inserção, cada caractere nesse intervalo acabaria tendo pelo menos dois tópicos de comentários (um existente e outro o novo que acabamos de permitir), dificultando a determinação de qual selecionar quando o usuário clica nesse personagem mais tarde.
Olhando para esta regra, pode-se perguntar por que precisamos dela em primeiro lugar se já temos a Regra do Menor Intervalo de Comentários que nos permite selecionar o menor intervalo de texto. Por que não permitir todas as combinações de sobreposições se podemos usar a primeira regra para deduzir o segmento de comentário correto a ser exibido? Como alguns dos exemplos que discutimos anteriormente, a primeira regra funciona para muitos cenários, mas não para todos. Com a Regra de Inserção, tentamos minimizar o número de cenários em que a primeira regra não pode nos ajudar e temos que recorrer à Barra Lateral como a única maneira de o usuário acessar esse tópico de comentários. A regra de inserção também evita sobreposições exatas de threads de comentários. Esta regra é comumente implementada por muitos editores populares.
Abaixo está um exemplo onde se esta regra não existisse, nós permitiríamos o Comentário Thread #3 e então, como resultado da primeira regra, #3 não estaria acessível, pois se tornaria o mais longo.
Observação : ter essa regra não significa que nunca conteríamos comentários sobrepostos totalmente. A coisa complicada sobre a sobreposição de comentários é que, apesar das regras, a ordem na qual os comentários são inseridos ainda pode nos deixar em um estado que não queremos que a sobreposição esteja. Voltando ao nosso exemplo dos comentários sobre a palavra 'designers ' anteriormente, o segmento de comentário mais longo inserido lá foi o último a ser adicionado para que a Regra de Inserção permitisse e acabamos com uma situação totalmente contida — #1 e #2 contidos dentro de #3. Isso é bom porque a Regra do menor intervalo de comentários nos ajudaria lá fora.
Implementaremos a Regra do Menor Intervalo de Comentários na próxima seção, onde implementamos a seleção de encadeamentos de comentários. Como agora temos um botão da barra de ferramentas para inserir comentários, podemos implementar a Regra de Inserção imediatamente verificando a regra quando o usuário tiver algum texto selecionado. Se a regra não for satisfeita, desabilitamos o botão Comentar para que os usuários não possam inserir um novo tópico de comentários no texto selecionado. Vamos começar!
# 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 ); }
A lógica nesta função é relativamente simples.
- Se a seleção do usuário for um acento circunflexo piscando, não permitimos a inserção de um comentário porque nenhum texto foi selecionado.
- Se a seleção do usuário não for recolhida, encontramos todos os nós de texto na seleção. Observe o uso do
mode: lowest
na chamada paraEditor.nodes
(uma função auxiliar do SlateJS) que nos ajuda a selecionar todos os nós de texto, já que os nós de texto são realmente as folhas da árvore do documento. - Se houver pelo menos um nó de texto que não tenha tópicos de comentários, podemos permitir a inserção. Usamos o util
getCommentThreadsOnTextNode
que escrevemos anteriormente aqui.
Agora usamos esta função util dentro da barra de ferramentas para controlar o estado desabilitado do botão.
# 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> );
Vamos testar a implementação da regra recriando nosso exemplo acima.
Um bom detalhe da experiência do usuário a ser destacado aqui é que, embora desabilitamos o botão da barra de ferramentas se o usuário tiver selecionado toda a linha de texto aqui, ele não completa a experiência do usuário. O usuário pode não entender completamente por que o botão está desabilitado e provavelmente ficará confuso porque não estamos respondendo à sua intenção de inserir um tópico de comentários lá. Abordaremos isso mais tarde, pois os popovers de comentários são construídos de forma que, mesmo que o botão da barra de ferramentas esteja desativado, o popover de um dos tópicos de comentários será exibido e o usuário ainda poderá deixar comentários.
Vamos testar também um caso onde existe algum nó de texto não comentado e a regra permite inserir um novo tópico de comentários.
Selecionando tópicos de comentários
Nesta seção, habilitamos o recurso em que o usuário clica em um nó de texto comentado e usamos a Regra de intervalo de comentário mais curto para determinar qual thread de comentário deve ser selecionado. As etapas do processo são:
- Encontre o segmento de comentário mais curto no nó de texto comentado no qual o usuário clica.
- Defina esse encadeamento de comentários para ser o encadeamento de comentários ativo. (Criamos um novo átomo de Recoil que será a fonte da verdade para isso.)
- Os nós de texto comentados ouviriam o estado Recoil e, se fizerem parte do encadeamento de comentários ativo, eles se destacariam de maneira diferente. Dessa forma, quando o usuário clica no tópico de comentários, todo o intervalo de texto se destaca, pois todos os nós de texto atualizarão sua cor de destaque.
Etapa 1: Implementação da regra de intervalo de comentários mais curto
Vamos começar com a Etapa 1, que é basicamente implementar a Regra do Menor Intervalo de Comentários. O objetivo aqui é encontrar o segmento de comentários do menor intervalo no nó de texto no qual o usuário clicou. Para encontrar o encadeamento de menor comprimento, precisamos calcular o comprimento de todos os encadeamentos de comentários nesse nó de texto. Os passos para fazer isso são:
- Obtenha todos os tópicos de comentários no nó de texto em questão.
- Atravesse em qualquer direção a partir desse nó de texto e continue atualizando os comprimentos de thread que estão sendo rastreados.
- Pare a travessia em uma direção quando chegarmos a uma das bordas abaixo:
- Um nó de texto não comentado (implicando que alcançamos a borda inicial/final mais distante de todos os tópicos de comentários que estamos rastreando).
- Um nó de texto onde todos os tópicos de comentários que estamos rastreando atingiram uma borda (início/fim).
- Não há mais nós de texto para percorrer nessa direção (o que implica que chegamos ao início ou ao fim do documento ou a um nó não-texto).
Como as travessias na direção direta e reversa são funcionalmente as mesmas, vamos escrever uma função auxiliar updateCommentThreadLengthMap
que basicamente usa um iterador de nó de texto. Ele continuará chamando o iterador e continuará atualizando os comprimentos de thread de rastreamento. Chamaremos essa função duas vezes — uma para a frente e outra para trás. 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.

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.
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 um componente se inscreva e também seja capaz de definir o valor do átomo de Recoil. Precisamos que o assinante saiba se esse nó de texto faz parte do encadeamento de comentários ativo para que ele possa se estilizar de maneira diferente. Confira a captura de tela abaixo, onde o tópico de comentários no meio está ativo e podemos ver seu alcance claramente.

Agora que temos todo o código para fazer a seleção de threads de comentários funcionar, vamos vê-lo em ação. Para testar bem nosso código de travessia, testamos alguns casos simples de sobreposição e alguns casos extremos como:
- Clicar em um nó de texto comentado no início/final do editor.
- Clicar em um nó de texto comentado com tópicos de comentários abrangendo vários parágrafos.
- Clicar em um nó de texto comentado logo antes de um nó de imagem.
- Clicar em links sobrepostos de um nó de texto comentado.
Como agora temos um átomo de Recoil para rastrear o ID do tópico de comentário ativo, um pequeno detalhe a ser observado é configurar o tópico de comentário recém-criado para ser o ativo quando o usuário usar o botão da barra de ferramentas para inserir um novo tópico de comentário. Isso nos permite, na próxima seção, mostrar o popover do tópico de comentários imediatamente após a inserção para que o usuário possa começar a adicionar comentários imediatamente.
# 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: O uso de useSetRecoilState
aqui (um gancho Recoil que expõe um setter para o átomo, mas não inscreve o componente em seu valor) é o que precisamos para a barra de ferramentas neste caso.
Adicionando Popovers de Tópicos de Comentários
Nesta seção, criamos um popover de comentários que faz uso do conceito de thread de comentários selecionado/ativo e mostra um popover que permite ao usuário adicionar comentários a esse thread de comentários. Antes de construí-lo, vamos dar uma olhada rápida em como ele funciona.
Ao tentar renderizar um popover de comentário próximo ao tópico de comentários que está ativo, encontramos alguns dos problemas que fizemos no primeiro artigo com um menu do editor de links. Neste ponto, é recomendável ler a seção do primeiro artigo que cria um Editor de links e os problemas de seleção que encontramos com isso.
Vamos primeiro trabalhar na renderização de um componente popover vazio no lugar certo com base no tópico de comentário ativo. A maneira como o popover funcionaria é:
- O popover de thread de comentário é renderizado somente quando há um ID de thread de comentário ativo. Para obter essa informação, ouvimos o átomo Recoil que criamos na seção anterior.
- Quando ele renderiza, encontramos o nó de texto na seleção do editor e renderizamos o popover próximo a ele.
- Quando o usuário clica em qualquer lugar fora do popover, definimos o thread de comentários ativo como
null
, desativando o thread de comentários e também fazendo o popover desaparecer.
# 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> ); }
Algumas coisas que devem ser chamadas para esta implementação do componente popover:
- Ele pega o
editorOffsets
e aselection
do componenteEditor
onde ele seria renderizado.editorOffsets
são os limites do componente Editor para que possamos calcular a posição do popover e aselection
pode ser a seleção atual ou anterior caso o usuário use um botão da barra de ferramentas fazendo com que aselection
se tornenull
. A seção sobre o Editor de links do primeiro artigo do link acima aborda isso em detalhes. - Como o
LinkEditor
do primeiro artigo e oCommentThreadPopover
aqui, ambos renderizam um popover em torno de um nó de texto, movemos essa lógica comum para um componenteNodePopover
que trata da renderização do componente alinhado ao nó de texto em questão. Seus detalhes de implementação são o que o componenteLinkEditor
tinha no primeiro artigo. -
NodePopover
usa um métodoonClickOutside
como um prop que é chamado se o usuário clicar em algum lugar fora do popover. Implementamos isso anexando o ouvinte de eventomousedown
aodocument
– conforme explicado em detalhes neste artigo Smashing sobre essa ideia. -
getFirstTextNodeAtSelection
obtém o primeiro nó de texto dentro da seleção do usuário que usamos para renderizar o popover. A implementação desta função usa os auxiliares do Slate para localizar o nó 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; }
Vamos implementar o retorno de chamada onClickOutside
que deve limpar o encadeamento de comentários ativo. No entanto, temos que levar em conta o cenário em que o popover do tópico de comentários está aberto e um determinado tópico está ativo e o usuário clica em outro tópico de comentários. Nesse caso, não queremos que o onClickOutside
redefina o encadeamento de comentários ativo, pois o evento click no outro componente CommentedText
deve definir o outro encadeamento de comentários para se tornar ativo. Não queremos interferir com isso no popover.
A maneira como fazemos isso é encontrar o Slate Node mais próximo do nó DOM onde o evento de clique aconteceu. Se esse nó Slate for um nó de texto e tiver comentários sobre ele, ignoramos a redefinição do átomo de recuo do segmento de comentário ativo. Vamos implementá-lo!
# 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 tem um método auxiliar toSlateNode
que retorna o nó Slate que mapeia para um nó DOM ou seu ancestral mais próximo se ele próprio não for um nó Slate. A implementação atual desse auxiliar gera um erro se não encontrar um nó Slate em vez de retornar null
. Lidamos com isso acima verificando o caso null
nós mesmos, o que é um cenário muito provável se o usuário clicar em algum lugar fora do editor onde os nós Slate não existem.
Agora podemos atualizar o componente Editor
para ouvir o activeCommentThreadIDAtom
e renderizar o popover somente quando um thread de comentários estiver ativo.
# 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> ... </> ); }
Vamos verificar se o popover carrega no lugar certo para o tópico de comentários correto e limpa o tópico de comentários ativo quando clicamos fora.
Agora passamos a permitir que os usuários adicionem comentários a um tópico de comentários e vejam todos os comentários desse tópico no popover. Vamos usar a família de átomos Recoil — commentThreadsState
que criamos anteriormente neste artigo para isso.
Os comentários em um thread de comentários são armazenados na matriz de comments
. Para habilitar a adição de um novo comentário, renderizamos uma entrada de formulário que permite ao usuário inserir um novo comentário. Enquanto o usuário digita o comentário, mantemos isso em uma variável de estado local — commentText
. Ao clicar no botão, anexamos o texto do comentário como o novo comentário ao array 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 : Embora renderizemos uma entrada para o usuário digitar um comentário, não necessariamente deixamos que ela fique em foco quando o popover é montado. Esta é uma decisão de experiência do usuário que pode variar de um editor para outro. Alguns editores não permitem que os usuários editem o texto enquanto o popover do tópico de comentários estiver aberto. No nosso caso, queremos permitir que o usuário edite o texto comentado ao clicar nele.
Vale a pena citar como acessamos os dados do thread de comentário específico da família de átomos Recoil — chamando o átomo como — commentThreadsState(threadID)
. Isso nos dá o valor do átomo e um setter para atualizar apenas aquele átomo na família. Se os comentários estiverem sendo carregados lentamente do servidor, o Recoil também fornece um gancho useRecoilStateLoadable
que retorna um objeto Loadable que nos informa sobre o estado de carregamento dos dados do átomo. Se ainda estiver carregando, podemos optar por mostrar um estado de carregamento no popover.
Agora, acessamos o threadData
e renderizamos a lista de comentários. Cada comentário é renderizado pelo 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> );
Abaixo está a implementação do CommentRow
que renderiza o texto do comentário e outros metadados como nome do autor e hora de criação. Usamos o módulo date-fns
para mostrar uma hora de criação formatada.
# 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> ); }
Extraímos isso para ser seu próprio componente, pois o reutilizamos mais tarde quando implementamos a barra lateral de comentários.
Neste ponto, nosso Comment Popover tem todo o código necessário para permitir a inserção de novos comentários e a atualização do estado de Recoil para os mesmos. Vamos verificar isso. No console do navegador, usando o Recoil Debug Observer que adicionamos anteriormente, podemos verificar se o átomo Recoil para o encadeamento de comentários está sendo atualizado corretamente à medida que adicionamos novos comentários ao encadeamento.
Adicionando uma barra lateral de comentários
Anteriormente neste artigo, explicamos por que, ocasionalmente, pode acontecer que as regras que implementamos impeçam que um determinado segmento de comentários não seja acessível clicando apenas em seu(s) nó(s) de texto — dependendo da combinação de sobreposição. Para esses casos, precisamos de uma barra lateral de comentários que permita ao usuário acessar todos e quaisquer tópicos de comentários no documento.
Uma barra lateral de comentários também é uma boa adição que se integra a um fluxo de trabalho de sugestão e revisão, onde um revisor pode navegar por todos os tópicos de comentários um após o outro em uma varredura e poder deixar comentários/respostas sempre que sentir necessidade. Antes de começarmos a implementar a barra lateral, há uma tarefa inacabada que cuidamos abaixo.
Inicializando o estado de recuo de threads de comentários
Quando o documento é carregado no editor, precisamos escanear o documento para encontrar todos os tópicos de comentários e adicioná-los aos átomos Recoil que criamos acima como parte do processo de inicialização. Vamos escrever uma função de utilitário no EditorCommentUtils
que verifica os nós de texto, encontra todos os tópicos de comentários e os adiciona ao átomo 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", }) ); }
Sincronização com armazenamento de back-end e consideração de desempenho
Para o contexto do artigo, como estamos focados exclusivamente na implementação da interface do usuário, apenas os inicializamos com alguns dados que nos permitem confirmar que o código de inicialização está funcionando.
No uso do sistema de comentários no mundo real, é provável que os encadeamentos de comentários sejam armazenados separadamente do próprio conteúdo do documento. Nesse caso, o código acima precisaria ser atualizado para fazer uma chamada de API que buscasse todos os metadados e comentários em todos os IDs de thread de comentário em commentThreads
. Depois que os tópicos de comentários são carregados, eles provavelmente serão atualizados, pois vários usuários adicionam mais comentários a eles em tempo real, alteram seu status e assim por diante. A versão de produção do sistema de comentários precisaria estruturar o armazenamento do Recoil de forma que possamos continuar sincronizando-o com o servidor. Se você optar por usar o Recoil para gerenciamento de estado, há alguns exemplos na API Atom Effects (experimentais até a redação deste artigo) que fazem algo semelhante.
Se um documento for muito longo e tiver muitos usuários colaborando nele em muitos encadeamentos de comentários, talvez tenhamos que otimizar o código de inicialização para carregar apenas encadeamentos de comentários para as primeiras páginas do documento. Como alternativa, podemos optar por carregar apenas os metadados leves de todos os tópicos de comentários em vez de toda a lista de comentários, que provavelmente é a parte mais pesada da carga útil.
Agora, vamos chamar essa função quando o componente Editor
for montado com o documento para que o estado Recoil seja inicializado corretamente.
# 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 o mesmo gancho personalizado — useAddCommentThreadToState
que usamos com a implementação do botão de comentário da barra de ferramentas para adicionar novos tópicos de comentários. Como temos o popover funcionando, podemos clicar em um dos tópicos de comentários pré-existentes no documento e verificar se ele mostra os dados que usamos para inicializar o tópico acima.

Agora que nosso estado foi inicializado corretamente, podemos começar a implementar a barra lateral. Todos os nossos threads de comentários na interface do usuário são armazenados na família de átomos Recoil — commentThreadsState
. Como destacado anteriormente, a maneira como iteramos todos os itens em uma família de átomos Recoil é rastreando as chaves/ids de átomos em outro átomo. Estamos fazendo isso com commentThreadIDsState
. Vamos adicionar o componente CommentSidebar
que itera através do conjunto de ids neste átomo e renderiza um componente CommentThread
para cada um.
# 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> ); }
Agora, implementamos o componente CommentThread
que escuta o átomo Recoil na família correspondente ao thread de comentários que está renderizando. Dessa forma, à medida que o usuário adiciona mais comentários no tópico no editor ou altera quaisquer outros metadados, podemos atualizar a barra lateral para refletir isso.
Como a barra lateral pode ficar muito grande para um documento com muitos comentários, ocultamos todos os comentários, exceto o primeiro, quando renderizamos a barra lateral. O usuário pode usar o botão 'Mostrar/Ocultar respostas' para mostrar/ocultar toda a sequência de comentários.
# 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> ); }
Reutilizamos o componente CommentRow
do popover, embora tenhamos adicionado um tratamento de design usando o prop showConnector
que basicamente faz com que todos os comentários pareçam conectados a um thread na barra lateral.
Agora, renderizamos o CommentSidebar
no Editor
e verificamos se ele mostra todos os tópicos que temos no documento e se atualiza corretamente à medida que adicionamos novos tópicos ou novos comentários aos tópicos existentes.
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Agora passamos para a implementação de uma interação popular da barra lateral de comentários encontrada nos editores:
Clicar em um tópico de comentários na barra lateral deve selecionar/ativar esse tópico de comentários. Também adicionamos um tratamento de design diferenciado para destacar um tópico de comentários na barra lateral se estiver ativo no editor. Para poder fazer isso, usamos o átomo Recoil — activeCommentThreadIDAtom
. Vamos atualizar o componente CommentThread
para dar suporte a isso.
# 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> );
Se olharmos de perto, temos um bug em nossa implementação de sincronizar o tópico de comentários ativo com a barra lateral. À medida que clicamos em diferentes tópicos de comentários na barra lateral, o tópico de comentários correto é realmente destacado no editor. No entanto, o popover de comentário não se move para o tópico de comentário ativo alterado. Ele permanece onde foi renderizado pela primeira vez. Se observarmos a implementação do Comment Popover, ele se renderiza no primeiro nó de texto na seleção do editor. Nesse ponto da implementação, a única maneira de selecionar um thread de comentários era clicar em um nó de texto para que pudéssemos confiar convenientemente na seleção do editor, pois ela foi atualizada pelo Slate como resultado do evento click. No evento onClick
acima, não atualizamos a seleção, mas apenas atualizamos o valor do átomo de recuo, fazendo com que a seleção de Slate permaneça inalterada e, portanto, o popover de comentário não se mova.
Uma solução para este problema é atualizar a seleção do editor junto com a atualização do átomo Recoil quando o usuário clica no tópico de comentários na barra lateral. As etapas para fazer isso são:
- Encontre todos os nós de texto que tenham este tópico de comentários neles que vamos definir como o novo tópico ativo.
- Classifique esses nós de texto na ordem em que aparecem no documento (usamos a API
Path.compare
do Slate para isso). - Calcule um intervalo de seleção que se estende desde o início do primeiro nó de texto até o final do último nó de texto.
- Defina o intervalo de seleção para ser a nova seleção do editor (usando a API
Transforms.select
do Slate).
Se quiséssemos apenas corrigir o bug, poderíamos encontrar o primeiro nó de texto na Etapa 1 que tem o tópico de comentários e defini-lo como a seleção do editor. No entanto, parece uma abordagem mais limpa selecionar todo o intervalo de comentários, pois realmente estamos selecionando o tópico de comentários.
Vamos atualizar a implementação do retorno de chamada onClick
para incluir as etapas acima.
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
contém o caminho para todos os nós de texto. Usamos a API Editor.point
para obter os pontos inicial e final nesse caminho. O primeiro artigo passa pelos conceitos de Localização do Slate. Eles também estão bem documentados na documentação do Slate.
Vamos verificar se esta implementação corrige o bug e o popover de comentário se move para o segmento de comentário ativo corretamente. Desta vez, também testamos com um caso de threads sobrepostos para garantir que não quebre lá.
Com a correção do bug, ativamos outra interação da barra lateral que ainda não discutimos. Se tivermos um documento muito longo e o usuário clicar em um tópico de comentários na barra lateral que está fora da janela de visualização, gostaríamos de rolar para essa parte do documento para que o usuário possa se concentrar no tópico de comentários no editor. Ao definir a seleção acima usando a API do Slate, obtemos isso de graça. Vamos vê-lo em ação abaixo.
Com isso, encerramos nossa implementação da barra lateral. No final do artigo, listamos algumas adições e aprimoramentos de recursos interessantes que podemos fazer na barra lateral de comentários que ajudam a elevar a experiência de comentários e revisões no editor.
Resolvendo e reabrindo comentários
Nesta seção, nos concentramos em permitir que os usuários marquem tópicos de comentários como 'Resolvidos' ou possam reabri-los para discussão, se necessário. De uma perspectiva de detalhes de implementação, esses são os metadados de status
em um encadeamento de comentários que alteramos à medida que o usuário executa essa ação. Do ponto de vista do usuário, esse é um recurso muito útil, pois permite afirmar que a discussão sobre algo no documento foi concluída ou precisa ser reaberta porque há algumas atualizações/novas perspectivas e assim por diante.
Para habilitar a alternância de status, adicionamos um botão ao CommentPopover
que permite ao usuário alternar entre os dois status: open
e 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 testarmos isso, vamos também dar à Barra Lateral de Comentários um tratamento de design diferenciado para comentários resolvidos, para que o usuário possa detectar facilmente quais tópicos de comentários não foram resolvidos ou abertos e se concentrar naqueles, se desejar.
# 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> ); }
Conclusão
Neste artigo, construímos a infraestrutura de interface do usuário principal para um sistema de comentários em um editor de rich text. O conjunto de funcionalidades que adicionamos aqui funciona como base para construir uma experiência de colaboração mais rica em um editor onde os colaboradores podem anotar partes do documento e conversar sobre elas. Adicionar uma barra lateral de comentários nos dá um espaço para ter mais funcionalidades de conversação ou baseadas em revisão para serem habilitadas no produto.
Nesse sentido, aqui estão alguns dos recursos que um Editor de Rich Text pode considerar adicionar além do que construímos neste artigo:
- Suporte para menções
@
para que os colaboradores possam marcar uns aos outros nos comentários; - Suporte para tipos de mídia como imagens e vídeos a serem adicionados a tópicos de comentários;
- Modo de sugestão no nível do documento que permite que os revisores façam edições no documento que aparecem como sugestões de alterações. Pode-se referir a esse recurso no Google Docs ou Change Tracking no Microsoft Word como exemplos;
- Aprimoramentos na barra lateral para pesquisar conversas por palavra-chave, filtrar tópicos por status ou autor(es) de comentários e assim por diante.