Добавление системы комментариев в редактор WYSIWYG
Опубликовано: 2022-03-10В последние годы мы видели, как совместная работа проникает во многие цифровые рабочие процессы и варианты использования во многих профессиях. Только в сообществе дизайнеров и разработчиков программного обеспечения мы видим, как дизайнеры совместно работают над артефактами дизайна, используя такие инструменты, как Figma, команды, занимающиеся спринтом и планированием проектов, используя такие инструменты, как Mural, и интервью, проводимые с помощью CoderPad. Все эти инструменты постоянно направлены на преодоление разрыва между онлайн-опытом и физическим миром при выполнении этих рабочих процессов и делают процесс совместной работы максимально богатым и беспрепятственным.
Для большинства инструментов совместной работы, подобных этим, возможность делиться мнениями друг с другом и обсуждать один и тот же контент является обязательной. В основе этой концепции лежит система комментирования, которая позволяет соавторам аннотировать части документа и обсуждать их. Наряду с созданием одного для текста в WYSIWYG-редакторе, статья пытается привлечь читателей к тому, как мы пытаемся взвесить все за и против и пытаемся найти баланс между сложностью приложения и пользовательским интерфейсом, когда речь идет о создании функций для WYSIWYG-редакторов или Текстовые процессоры в целом.
Представление комментариев в структуре документа
Чтобы найти способ представления комментариев в структуре данных документа с форматированным текстом, давайте рассмотрим несколько сценариев, в которых комментарии могут создаваться внутри редактора.
- Комментарии, созданные над текстом, для которого не заданы стили (базовый сценарий);
- Комментарии, созданные поверх текста, могут быть выделены полужирным/курсивом/подчеркнутым шрифтом и т. д.;
- Комментарии, которые каким-то образом перекрывают друг друга (частичное перекрытие, когда два комментария имеют только несколько общих слов, или полностью содержащиеся, когда текст одного комментария полностью содержится в тексте другого комментария);
- Комментарии, созданные над текстом внутри ссылки (особенно потому, что ссылки сами являются узлами в структуре нашего документа);
- Комментарии, которые охватывают несколько абзацев (особенно потому, что абзацы являются узлами в структуре нашего документа, а комментарии применяются к текстовым узлам, которые являются дочерними элементами абзаца).
Глядя на приведенные выше варианты использования, кажется, что комментарии в том, как они могут появляться в документе с форматированным текстом, очень похожи на стили символов (жирный шрифт, курсив и т. д.). Они могут перекрываться друг с другом, проходить по тексту в других типах узлов, таких как ссылки, и даже охватывать несколько родительских узлов, таких как абзацы.
По этой причине мы используем тот же метод для представления комментариев, что и для стилей символов, т. е. «метки» (так они называются в терминологии SlateJS). Метки — это обычные свойства узлов. Особенность заключается в том, что Slate API вокруг меток ( Editor.addMark
и Editor.removeMark
) обрабатывает изменение иерархии узлов, когда несколько меток применяются к одному и тому же диапазону текста. Это чрезвычайно полезно для нас, поскольку мы имеем дело с множеством различных комбинаций перекрывающихся комментариев.
Комментировать темы как отметки
Всякий раз, когда пользователь выбирает диапазон текста и пытается вставить комментарий, технически он начинает новую цепочку комментариев для этого текстового диапазона. Поскольку мы позволили бы им вставить комментарий и позже ответить на этот комментарий, мы рассматриваем это событие как вставку новой ветки комментариев в документ.
Мы представляем ветки комментариев в виде меток: каждая ветка комментариев представлена меткой с именем commentThread_threadID
, где threadID
— это уникальный идентификатор, который мы присваиваем каждой ветке комментариев. Таким образом, если над одним и тем же диапазоном текста есть две ветки комментариев, для него будут установлены два свойства, равные true
— commentThread_thread1
и commentThread_thread2
. Здесь ветки комментариев очень похожи на стили символов, поскольку, если бы один и тот же текст был выделен полужирным и курсивом, оба его свойства были бы установлены в true
— bold
и italic
.
Прежде чем мы углубимся в настройку этой структуры, стоит посмотреть, как меняются текстовые узлы по мере того, как к ним применяются потоки комментариев. Как это работает (как и с любой меткой), заключается в том, что когда свойство метки устанавливается для выделенного текста, Slate Editor.addMark API разделяет текстовые узлы, если это необходимо, так что в результирующей структуре текстовые узлы настроены таким образом, что каждый текстовый узел имеет точно такое же значение метки.
Чтобы лучше понять это, взгляните на следующие три примера, которые показывают состояние текстовых узлов до и после того, как цепочка комментариев вставлена в выделенный текст:
Выделение прокомментированного текста
Теперь, когда мы знаем, как мы собираемся представлять комментарии в структуре документа, давайте добавим несколько в пример документа из первой статьи и настроим редактор, чтобы они действительно отображались выделенными. Поскольку в этой статье у нас будет много служебных функций для работы с комментариями, мы создадим модуль EditorCommentUtils
, в котором будут размещены все эти служебные программы. Для начала мы создаем функцию, которая создает метку для заданного идентификатора цепочки комментариев. Затем мы используем это, чтобы вставить несколько веток комментариев в наш ExampleDocument
.
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
На изображении ниже красным выделены диапазоны текста, которые мы добавили в качестве примеров веток комментариев в следующем фрагменте кода. Обратите внимание, что текст «Ричард МакКлинток» имеет две ветки комментариев, которые перекрывают друг друга. В частности, это случай, когда одна ветка комментариев полностью содержится внутри другой.
# 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, }, ... ];
В этой статье мы сосредоточимся на пользовательском интерфейсе системы комментариев, поэтому мы назначаем им идентификаторы в примере документа напрямую, используя uuid пакета npm. Очень вероятно, что в рабочей версии редактора эти идентификаторы создаются серверной службой.
Теперь мы сосредоточимся на настройке редактора, чтобы эти текстовые узлы отображались выделенными. Чтобы сделать это, при рендеринге текстовых узлов нам нужен способ узнать, есть ли на нем потоки комментариев. Для этого мы добавляем getCommentThreadsOnTextNode
. Мы основываемся на компоненте StyledText
, созданном в первой статье, для обработки случая, когда он может пытаться отобразить текстовый узел с комментариями. Поскольку у нас есть дополнительные функции, которые позже будут добавлены к комментируемым текстовым узлам, мы создаем компонент CommentedText
, который отображает прокомментированный текст. StyledText
проверит, есть ли комментарии к текстовому узлу, который он пытается отобразить. Если это так, он отображает CommentedText
. Он использует getCommentThreadsOnTextNode
, чтобы вывести это.
# 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; }
В первой статье был создан компонент StyledText
, который отображает текстовые узлы (обрабатывает стили символов и т. д.). Мы расширяем этот компонент, чтобы использовать вышеуказанную утилиту и отображать компонент CommentedText
, если узел имеет комментарии к нему.
# 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>; }
Ниже приведена реализация CommentedText
, которая отображает текстовый узел и прикрепляет CSS, который показывает его выделенным.
# 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; }
Собрав весь приведенный выше код вместе, мы теперь видим текстовые узлы с потоками комментариев, выделенными в редакторе.
Примечание . В настоящее время пользователи не могут определить, есть ли в определенном тексте перекрывающиеся комментарии. Весь выделенный текстовый диапазон выглядит как одна цепочка комментариев. Мы обратимся к этому позже в статье, где мы представим концепцию активной ветки комментариев, которая позволяет пользователям выбирать конкретную ветку комментариев и иметь возможность видеть ее диапазон в редакторе.
Хранилище пользовательского интерфейса для комментариев
Прежде чем мы добавим функциональность, которая позволяет пользователю вставлять новые комментарии, мы сначала настраиваем состояние пользовательского интерфейса для хранения наших потоков комментариев. В этой статье мы используем RecoilJS в качестве нашей библиотеки управления состоянием для хранения веток комментариев, комментариев, содержащихся внутри веток, и других метаданных, таких как время создания, статус, автор комментария и т. д. Давайте добавим Recoil в наше приложение:
> yarn add recoil
Мы используем атомы Recoil для хранения этих двух структур данных. Если вы не знакомы с Recoil, атомы — это то, что удерживает состояние приложения. Для разных частей состояния приложения обычно нужно настраивать разные атомы. Семейство атомов — это набор атомов — его можно рассматривать как Map
от уникального ключа, идентифицирующего атом, до самих атомов. На этом этапе стоит пройтись по основным концепциям Recoil и ознакомиться с ними.
В нашем случае мы храним потоки комментариев как семейство Atom, а затем оборачиваем наше приложение в компонент RecoilRoot
. RecoilRoot
применяется для предоставления контекста, в котором будут использоваться значения атома. Мы создаем отдельный модуль CommentState
, в котором хранятся наши определения атомов Recoil, поскольку мы добавим больше определений атомов позже в этой статье.
# 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([]), });
Стоит упомянуть несколько вещей об этих определениях атомов:
- Каждый атом/семейство атомов однозначно идентифицируется
key
и может быть настроен со значением по умолчанию. - По мере того, как мы продолжим работу в этой статье, нам понадобится способ перебора всех веток комментариев, что в основном означает необходимость перебора семейства атомов
commentThreadsState
. На момент написания этой статьи способ сделать это с помощью Recoil — настроить еще один атом, который содержит все идентификаторы семейства атомов. Мы делаем это с помощьюcommentThreadIDsState
выше. Оба эти атома должны синхронизироваться всякий раз, когда мы добавляем/удаляем ветки комментариев.
Мы добавляем оболочку RecoilRoot
в наш корневой компонент App
, чтобы мы могли использовать эти атомы позже. Документация Recoil также содержит полезный компонент Debugger, который мы берем как есть и помещаем в наш редактор. Этот компонент оставит журналы console.debug
на нашей консоли Dev, поскольку атомы Recoil обновляются в режиме реального времени.
# 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. }
Нам также нужно добавить код, который инициализирует наши атомы потоками комментариев, которые уже существуют в документе (например, те, которые мы добавили в наш пример документа в предыдущем разделе). Мы делаем это позже, когда создаем боковую панель комментариев, которая должна читать все ветки комментариев в документе.
На этом этапе мы загружаем наше приложение, убеждаемся, что нет ошибок, указывающих на нашу настройку Recoil, и двигаемся вперед.
Добавление новых комментариев
В этом разделе мы добавляем кнопку на панель инструментов, которая позволяет пользователю добавлять комментарии (т. е. создавать новую ветку комментариев) для выбранного диапазона текста. Когда пользователь выбирает диапазон текста и нажимает эту кнопку, нам нужно сделать следующее:
- Назначьте уникальный идентификатор новой вставляемой ветке комментариев.
- Добавьте новую метку в структуру документа Slate с идентификатором, чтобы пользователь видел этот текст выделенным.
- Добавьте новую ветку комментариев к атомам Recoil, которые мы создали в предыдущем разделе.
Давайте добавим в EditorCommentUtils
функцию util, которая выполняет #1 и #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; }
Используя концепцию меток для хранения каждой ветки комментариев как отдельной метки, мы можем просто использовать API Editor.addMark
, чтобы добавить новую ветку комментариев в выбранный диапазон текста. Этот вызов сам по себе обрабатывает все различные случаи добавления комментариев — некоторые из которых мы описали в предыдущем разделе — частично перекрывающиеся комментарии, комментарии внутри/перекрывающиеся ссылки, комментарии по жирному/курсивному тексту, комментарии, охватывающие абзацы и так далее. Этот вызов API корректирует иерархию узлов, чтобы создать столько новых текстовых узлов, сколько необходимо для обработки этих случаев.
addCommentThreadToState
— это функция обратного вызова, которая обрабатывает шаг №3 — добавление нового потока комментариев в Recoil atom. Мы реализуем это next как пользовательский обработчик обратного вызова, чтобы его можно было использовать повторно. Этот обратный вызов должен добавить новый поток комментариев к обоим атомам — commentThreadsState
и commentThreadIDsState
. Чтобы сделать это, мы используем хук useRecoilCallback
. Этот хук можно использовать для создания обратного вызова, который получает несколько вещей, которые можно использовать для чтения/установки данных атома. Сейчас нас интересует функция set
, которую можно использовать для обновления значения атома как 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); }, [] ); }
Первый вызов set
добавляет новый идентификатор к существующему набору идентификаторов потока комментариев и возвращает новый Set
(который становится новым значением атома).
Во втором вызове мы получаем атом для идентификатора из семейства атомов — commentThreadsState
как commentThreadsState(id)
, а затем устанавливаем threadData
как его значение. atomFamilyName(atomID)
— это то, как Recoil позволяет нам получить доступ к атому из его семейства атомов, используя уникальный ключ. Грубо говоря, мы могли бы сказать, что если commentThreadsState
был javascript Map, то этот вызов в основном — commentThreadsState.set(id, threadData)
.
Теперь, когда у нас есть весь этот код для обработки вставки новой ветки комментариев в документ и атомов Recoil, давайте добавим кнопку на нашу панель инструментов и свяжем ее с вызовом этих функций.
# 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> ); }
Примечание . Мы используем onMouseDown
, а не onClick
, что привело бы к тому, что редактор потерял бы фокус и выбор стал бы null
. Мы обсудили это более подробно в разделе вставки ссылок в первой статье.
В приведенном ниже примере мы видим вставку в действии для простой цепочки комментариев и перекрывающейся цепочки комментариев со ссылками. Обратите внимание, как мы получаем обновления от отладчика Recoil, подтверждающие, что наше состояние обновляется правильно. Мы также проверяем, что новые текстовые узлы создаются по мере добавления потоков в документ.
Перекрывающиеся комментарии
Прежде чем мы приступим к добавлению дополнительных функций в нашу систему комментариев, нам нужно принять некоторые решения относительно того, как мы будем поступать с перекрывающимися комментариями и их различными комбинациями в редакторе. Чтобы понять, зачем нам это нужно, давайте взглянем на то, как работает всплывающее окно комментариев — функциональность, которую мы создадим позже в этой статье. Когда пользователь нажимает на определенный текст с веткой комментариев, мы «выбираем» ветку комментариев и показываем всплывающее окно, где пользователь может добавлять комментарии к этой ветке.
Как видно из приведенного выше видео, слово «дизайнеры» теперь является частью трех веток комментариев. Итак, у нас есть две ветки комментариев, которые пересекаются друг с другом по одному слову. И обе эти ветки комментариев (№1 и №2) полностью содержатся внутри более длинного диапазона текста ветки комментариев (№3). Это вызывает несколько вопросов:
- Какую ветку комментариев мы должны выбрать и показать, когда пользователь нажимает на слово «дизайнеры»?
- Основываясь на том, как мы решаем ответить на вышеуказанный вопрос, будет ли у нас когда-либо случай перекрытия, когда нажатие на любое слово никогда не активирует определенную цепочку комментариев, и к этой цепочке вообще нельзя будет получить доступ?
Это означает, что в случае с перекрывающимися комментариями самое важное, что нужно учитывать, это — после того, как пользователь вставил цепочку комментариев, будет ли у них способ выбрать эту цепочку комментариев в будущем, щелкнув какой-либо текст внутри. Это? Если нет, мы, вероятно, не хотим позволять им вставлять его в первую очередь. Чтобы обеспечить соблюдение этого принципа в нашем редакторе большую часть времени, мы вводим два правила относительно перекрывающихся комментариев и реализуем их в нашем редакторе.
Прежде чем мы определим эти правила, стоит отметить, что разные редакторы и текстовые процессоры по-разному подходят к перекрывающимся комментариям. Для простоты некоторые редакторы вообще не допускают дублирования комментариев. В нашем случае мы пытаемся найти золотую середину, не допуская слишком сложных случаев дублирования, но по-прежнему допуская дублирование комментариев, чтобы пользователи могли иметь более богатый опыт совместной работы и проверки.
Правило кратчайшего диапазона комментариев
Это правило помогает нам ответить на поставленный выше вопрос №1 о том, какую цепочку комментариев выбрать, если пользователь нажимает на текстовый узел, на котором есть несколько цепочек комментариев. Правило:
«Если пользователь нажимает на текст, содержащий несколько веток комментариев, мы находим ветку комментариев с кратчайшим текстовым диапазоном и выбираем ее».
Интуитивно имеет смысл сделать это, чтобы у пользователя всегда был способ добраться до самой внутренней ветки комментариев, которая полностью содержится внутри другой ветки комментариев. Для других условий (частичное перекрытие или отсутствие перекрытия) должен быть некоторый текст, который имеет только одну цепочку комментариев, поэтому должно быть легко использовать этот текст для выбора этой цепочки комментариев. Это случай полного (или плотного ) перекрытия потоков и зачем нам нужно это правило.
Давайте рассмотрим довольно сложный случай перекрытия, который позволяет нам использовать это правило и «поступать правильно» при выборе ветки комментариев.
В приведенном выше примере пользователь вставляет следующие ветки комментариев в указанном порядке:
- Комментарий Thread #1 над символом 'B' (длина = 1).
- Прокомментируйте тему № 2 над «AB» (длина = 2).
- Прокомментируйте тему № 3 над «BC» (длина = 2).
В конце этих вставок, благодаря тому, как Slate разбивает текстовые узлы метками, у нас будет три текстовых узла — по одному для каждого символа. Теперь, если пользователь нажимает «B», следуя правилу наименьшей длины, мы выбираем поток № 1, поскольку он является самым коротким из трех по длине. Если мы этого не сделаем, у нас не будет возможности выбрать поток комментариев № 1, поскольку он имеет длину всего один символ и также является частью двух других потоков.
Хотя это правило упрощает отображение веток комментариев меньшей длины, мы можем столкнуться с ситуациями, когда более длинные ветки комментариев становятся недоступными, поскольку все символы, содержащиеся в них, являются частью какой-либо другой ветки более коротких комментариев. Давайте посмотрим на пример для этого.
Предположим, у нас есть 100 символов (скажем, символ «А» набран 100 раз), и пользователь вставляет ветки комментариев в следующем порядке:
- Комментарий Тема №1 из диапазона 20,80
- Комментарий Тема №2 диапазона 0,50
- Комментарий Тема № 3 из диапазона 51 100
Как вы можете видеть в приведенном выше примере, если мы будем следовать правилу, которое мы только что описали, щелкнув любой символ между # 20 и # 80, всегда будут выбираться потоки # 2 или # 3, поскольку они короче # 1 и, следовательно, # 1. не было бы возможности выбора. Другой сценарий, в котором это правило может оставить нас в нерешительности относительно того, какую цепочку комментариев выбрать, — это когда на текстовом узле имеется более одной цепочки комментариев одинаковой кратчайшей длины.
Для такой комбинации перекрывающихся комментариев и многих других подобных комбинаций, которые можно было бы придумать, когда соблюдение этого правила делает определенную цепочку комментариев недоступной при нажатии на текст, мы создаем боковую панель комментариев позже в этой статье, которая дает пользователю представление обо всех цепочках комментариев. присутствуют в документе, чтобы они могли щелкнуть эти темы на боковой панели и активировать их в редакторе, чтобы увидеть диапазон комментария. Мы по-прежнему хотели бы иметь это правило и реализовать его, поскольку оно должно охватывать множество пересекающихся сценариев, за исключением менее вероятных примеров, которые мы привели выше. Мы приложили все усилия к этому правилу в первую очередь потому, что увидеть выделенный текст в редакторе и щелкнуть по нему, чтобы прокомментировать, — это более интуитивно понятный способ доступа к комментарию к тексту, чем просто использование списка комментариев на боковой панели.
Правило вставки
Правило:
«Если текст, который пользователь выбрал и пытается прокомментировать, уже полностью покрыт цепочками комментариев, не разрешайте эту вставку».
Это так, потому что, если бы мы разрешили эту вставку, каждый символ в этом диапазоне в конечном итоге имел бы по крайней мере две цепочки комментариев (одну существующую и другую новую, которую мы только что разрешили), что затрудняет нам определение, какую из них выбрать, когда пользователь нажимает на этот символ позже.
Глядя на это правило, можно задаться вопросом, зачем оно вообще нам нужно, если у нас уже есть правило кратчайшего диапазона комментариев, которое позволяет нам выбирать наименьший диапазон текста. Почему бы не разрешить все комбинации перекрытий, если мы можем использовать первое правило, чтобы вывести правильную цепочку комментариев для отображения? Как и в некоторых примерах, которые мы обсуждали ранее, первое правило работает во многих сценариях, но не во всех. С помощью правила вставки мы пытаемся свести к минимуму количество сценариев, в которых первое правило не может нам помочь, и нам приходится использовать боковую панель как единственный способ для пользователя получить доступ к этой ветке комментариев. Правило вставки также предотвращает точное перекрытие веток комментариев. Это правило обычно реализуется многими популярными редакторами.
Ниже приведен пример, в котором, если бы этого правила не существовало, мы бы разрешили ветку комментариев № 3, а затем в результате первого правила № 3 был бы недоступен, поскольку он стал бы самым длинным по длине.
Примечание . Наличие этого правила не означает, что мы никогда полностью не ограничим перекрывающиеся комментарии. Сложность с перекрывающимися комментариями заключается в том, что, несмотря на правила, порядок, в котором комментарии вставляются, все еще может оставить нас в состоянии, в котором мы не хотели бы, чтобы перекрытие было. Возвращаясь к нашему примеру с комментариями к слову «дизайнеры». ' ранее самая длинная цепочка комментариев, вставленная туда, добавлялась последней, поэтому Правило вставки разрешало это, и в итоге мы получили полностью замкнутую ситуацию — № 1 и № 2, содержащиеся внутри № 3. Это нормально, потому что Правило кратчайшего диапазона комментариев поможет нам в этом.
Мы реализуем правило кратчайшего диапазона комментариев в следующем разделе, где реализуем выбор цепочек комментариев. Поскольку теперь у нас есть кнопка на панели инструментов для вставки комментариев, мы можем сразу реализовать правило вставки, проверяя правило, когда пользователь выделяет какой-либо текст. Если правило не выполняется, мы отключим кнопку «Комментарий», чтобы пользователи не могли вставлять новую цепочку комментариев к выделенному тексту. Давайте начнем!
# 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 ); }
Логика этой функции относительно проста.
- Если пользовательский выбор представляет собой мигающий курсор, мы не разрешаем вставлять туда комментарий, поскольку текст не был выделен.
- Если выделение пользователя не свернуто, мы находим все текстовые узлы в выделении. Обратите внимание на использование
mode: lowest
low в вызовеEditor.nodes
(вспомогательная функция от SlateJS), который помогает нам выбрать все текстовые узлы, поскольку текстовые узлы на самом деле являются листьями дерева документа. - Если есть хотя бы один текстовый узел, на котором нет потоков комментариев, мы можем разрешить вставку. Мы используем
getCommentThreadsOnTextNode
, которую мы написали здесь ранее.
Теперь мы используем эту вспомогательную функцию внутри панели инструментов для управления отключенным состоянием кнопки.
# 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> );
Давайте проверим реализацию правила, воссоздав наш пример выше.
Прекрасная деталь взаимодействия с пользователем, на которую следует обратить внимание, заключается в том, что, хотя мы отключаем кнопку панели инструментов, если пользователь выбрал здесь всю строку текста, это не завершает взаимодействие для пользователя. Пользователь может не до конца понять, почему кнопка отключена, и, вероятно, запутается, что мы не отвечаем на его намерение вставить туда ветку комментариев. Мы обратимся к этому позже, поскольку всплывающие окна комментариев построены таким образом, что даже если кнопка на панели инструментов отключена, всплывающее окно для одной из веток комментариев будет отображаться, и пользователь все равно сможет оставлять комментарии.
Давайте также проверим случай, когда есть какой-то незакомментированный текстовый узел, а правило позволяет вставить новую цепочку комментариев.
Выбор цепочек комментариев
В этом разделе мы включаем функцию, при которой пользователь щелкает текстовый узел с комментариями, и мы используем правило кратчайшего диапазона комментариев, чтобы определить, какую цепочку комментариев следует выбрать. Шаги в этом процессе:
- Найдите самую короткую цепочку комментариев к комментируемому текстовому узлу, на который нажимает пользователь.
- Установите эту ветку комментариев как активную ветку комментариев. (Мы создаем новый атом Отдачи, который будет для этого источником истины.)
- Текстовые узлы с комментариями будут слушать состояние Recoil, и если они являются частью активного потока комментариев, они будут выделять себя по-разному. Таким образом, когда пользователь нажимает на ветку комментариев, выделяется весь текстовый диапазон, поскольку все текстовые узлы обновят свой цвет выделения.
Шаг 1. Внедрение правила кратчайшего диапазона комментариев
Давайте начнем с шага № 1, который в основном реализует правило кратчайшего диапазона комментариев. Цель здесь — найти ветку комментариев кратчайшего диапазона в текстовом узле, на котором щелкнул пользователь. Чтобы найти самую короткую нить, нам нужно вычислить длину всех цепочек комментариев в этом текстовом узле. Шаги для этого:
- Получить все потоки комментариев в рассматриваемом текстовом узле.
- Перейдите в любом направлении от этого текстового узла и продолжайте обновлять отслеживаемые длины потоков.
- Остановите обход в направлении, когда мы достигли одного из следующих ребер:
- Незакомментированный текстовый узел (подразумевается, что мы достигли самого дальнего начального/конечного края всех веток комментариев, которые мы отслеживаем).
- Текстовый узел, в котором все цепочки комментариев, которые мы отслеживаем, достигли края (начало/конец).
- В этом направлении больше нет текстовых узлов (подразумевается, что мы либо достигли начала, либо конца документа, либо нетекстового узла).
Поскольку обходы в прямом и обратном направлении функционально одинаковы, мы собираемся написать вспомогательную функцию updateCommentThreadLengthMap
, которая в основном использует итератор текстового узла. Он будет продолжать вызывать итератор и обновлять длины потоков отслеживания. Мы будем вызывать эту функцию дважды — один раз для прямого и один раз для обратного направления. 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> ); }
Этот компонент использует useRecoilState
, который позволяет компоненту подписываться на него, а также иметь возможность устанавливать значение атома отдачи. Нам нужно, чтобы подписчик знал, является ли этот текстовый узел частью активной ветки комментариев, чтобы он мог стилизовать себя по-разному. Посмотрите на скриншот ниже, где ветка комментариев посередине активна, и мы ясно видим ее диапазон.
Теперь, когда у нас есть весь код, обеспечивающий работу выбора веток комментариев, давайте посмотрим на это в действии. Чтобы хорошо протестировать наш код обхода, мы тестируем несколько простых случаев перекрытия и некоторые крайние случаи, такие как:
- Щелчок по узлу комментируемого текста в начале/конце редактора.
- Щелчок по комментируемому текстовому узлу с потоками комментариев, охватывающими несколько абзацев.
- Щелчок по узлу комментируемого текста прямо перед узлом изображения.
- Щелчок по комментируемому текстовому узлу, перекрывающему ссылки.
Поскольку теперь у нас есть атом Recoil для отслеживания идентификатора активного потока комментариев, нужно позаботиться об одной крошечной детали — сделать вновь созданный поток комментариев активным, когда пользователь использует кнопку на панели инструментов для вставки нового потока комментариев. Это позволяет нам в следующем разделе отображать всплывающее окно цепочки комментариев сразу после вставки, чтобы пользователь мог сразу начать добавлять комментарии.
# 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>; };
Примечание . Использование здесь useSetRecoilState
(хука Recoil, который предоставляет сеттер для атома, но не подписывает компонент на его значение) — это то, что нам нужно для панели инструментов в этом случае.
Добавление всплывающих окон веток комментариев
В этом разделе мы создадим всплывающее окно комментариев, которое использует концепцию выбранной/активной ветки комментариев и показывает всплывающее окно, которое позволяет пользователю добавлять комментарии в эту ветку комментариев. Прежде чем мы построим его, давайте быстро посмотрим, как он работает.
При попытке отобразить всплывающее окно комментариев рядом с активной веткой комментариев мы сталкиваемся с некоторыми проблемами, которые мы делали в первой статье с меню редактора ссылок. На этом этапе рекомендуется прочитать раздел в первой статье, посвященный созданию редактора ссылок, и проблемы выбора, с которыми мы сталкиваемся при этом.
Давайте сначала поработаем над рендерингом пустого всплывающего компонента в нужном месте в зависимости от того, какая активная ветка комментариев. Как будет работать всплывающее окно:
- Всплывающее окно потока комментариев отображается только при наличии идентификатора активного потока комментариев. Чтобы получить эту информацию, мы слушаем атом Recoil, который мы создали в предыдущем разделе.
- Когда он визуализируется, мы находим текстовый узел в выделении редактора и визуализируем всплывающее окно рядом с ним.
- Когда пользователь щелкает в любом месте за пределами всплывающего окна, мы устанавливаем активную ветку комментариев как
null
, тем самым деактивируя ветку комментариев, а всплывающее окно также исчезает.
# 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> ); }
Несколько вещей, которые должны быть вызваны для этой реализации компонента всплывающего окна:
- Он принимает
editorOffsets
иselection
из компонентаEditor
, где он будет отображаться.editorOffsets
— это границы компонента Editor, чтобы мы могли вычислить положение всплывающего окна, аselection
мог быть текущим или предыдущим выбором в случае, если пользователь использовал кнопку на панели инструментов, в результате чегоselection
сталnull
. Раздел о редакторе ссылок из первой статьи, указанной выше, подробно описывает их. - Поскольку
LinkEditor
из первой статьи иCommentThreadPopover
здесь отображают всплывающее окно вокруг текстового узла, мы перенесли эту общую логику в компонентNodePopover
, который обрабатывает отображение компонента, выровненного с рассматриваемым текстовым узлом. Детали его реализации аналогичны тому, что было в компонентеLinkEditor
в первой статье. -
NodePopover
использует методonClickOutside
в качестве реквизита, который вызывается, если пользователь щелкает где-то за пределами всплывающего окна. Мы реализуем это, присоединив кdocument
прослушиватель событийmousedown
— как подробно описано в этой статье Smashing об этой идее. -
getFirstTextNodeAtSelection
получает первый текстовый узел внутри выделения пользователя, который мы используем для отображения всплывающего окна. Реализация этой функции использует помощники Slate для поиска текстового узла.
# 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; }
Давайте реализуем обратный вызов onClickOutside
, который должен очищать активный поток комментариев. Однако мы должны учитывать сценарий, когда всплывающее окно ветки комментариев открыто, а определенная ветка активна, а пользователь случайно нажимает на другую ветку комментариев. В этом случае мы не хотим, чтобы onClickOutside
сбрасывал активную цепочку комментариев, поскольку событие click в другом компоненте CommentedText
должно сделать другую цепочку комментариев активной. Мы не хотим вмешиваться в это во всплывающем окне.
Мы делаем это следующим образом: находим Slate Node, ближайший к узлу DOM, где произошло событие клика. Если этот узел Slate является текстовым узлом и содержит комментарии к нему, мы пропускаем сброс активного потока комментариев Recoil Atom. Давайте реализуем это!
# 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 есть вспомогательный метод toSlateNode
, который возвращает узел Slate, который сопоставляется с узлом DOM или его ближайшим предком, если сам не является узлом Slate. Текущая реализация этого помощника выдает ошибку, если не может найти узел Slate вместо возврата null
. Мы обрабатываем это выше, проверяя null
регистр самостоятельно, что является очень вероятным сценарием, если пользователь щелкает где-то за пределами редактора, где нет узлов Slate.
Теперь мы можем обновить компонент Editor
, чтобы он прослушивал activeCommentThreadIDAtom
и отображал всплывающее окно только тогда, когда поток комментариев активен.
# 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> ... </> ); }
Давайте проверим, что всплывающее окно загружается в нужном месте для правильной ветки комментариев и очищает активную ветку комментариев, когда мы щелкаем снаружи.
Теперь мы переходим к тому, чтобы пользователи могли добавлять комментарии в ветку комментариев и видеть все комментарии этой ветки во всплывающем окне. Мы собираемся использовать семейство атомов Recoil — commentThreadsState
, которое мы создали для этого ранее в этой статье.
Комментарии в потоке комментариев хранятся в массиве comments
. Чтобы включить добавление нового комментария, мы отображаем ввод формы, который позволяет пользователю ввести новый комментарий. Пока пользователь печатает комментарий, мы сохраняем его в локальной переменной состояния — commentText
. По нажатию кнопки мы добавляем текст комментария в качестве нового комментария в массив 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> ); }
Примечание . Несмотря на то, что мы визуализируем ввод для ввода комментария пользователем, мы не обязательно позволяем ему сфокусироваться при монтировании всплывающего окна. Это решение пользовательского опыта, которое может варьироваться от одного редактора к другому. Некоторые редакторы не позволяют пользователям редактировать текст, пока открыто всплывающее окно ветки комментариев. В нашем случае мы хотим, чтобы пользователь мог редактировать прокомментированный текст, когда он нажимает на него.
Стоит указать, как мы получаем доступ к данным конкретного потока комментариев из семейства атомов Recoil — вызывая атом как — commentThreadsState(threadID)
. Это дает нам значение атома и сеттер для обновления только этого атома в семействе. Если комментарии лениво загружаются с сервера, Recoil также предоставляет хук useRecoilStateLoadable
, который возвращает объект Loadable, который сообщает нам о состоянии загрузки данных атома. Если он все еще загружается, мы можем выбрать отображение состояния загрузки во всплывающем окне.
Теперь мы получаем доступ к threadData
и отображаем список комментариев. Каждый комментарий обрабатывается компонентом CommentRow
.
# src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );
Ниже приведена реализация CommentRow
, которая отображает текст комментария и другие метаданные, такие как имя автора и время создания. Мы используем модуль date-fns
, чтобы показать отформатированное время создания.
# 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> ); }
Мы выделили это как отдельный компонент, поскольку мы повторно используем его позже, когда реализуем боковую панель комментариев.
На данный момент в нашем всплывающем окне комментариев есть весь код, необходимый для вставки новых комментариев и обновления состояния отдачи для них. Давайте проверим это. В консоли браузера, используя Recoil Debug Observer, который мы добавили ранее, мы можем убедиться, что атом Recoil для ветки комментариев обновляется правильно, когда мы добавляем новые комментарии в ветку.
Добавление боковой панели комментариев
Ранее в статье мы указывали, почему иногда может случиться так, что реализованные нами правила препятствуют тому, чтобы определенная ветка комментариев была недоступна, если щелкнуть только ее текстовый узел (узлы) — в зависимости от комбинации перекрытий. Для таких случаев нам нужна боковая панель комментариев, которая позволяет пользователю получить доступ ко всем веткам комментариев в документе.
Боковая панель комментариев также является хорошим дополнением, которое вплетается в рабочий процесс предложения и обзора, где рецензент может перемещаться по всем веткам комментариев один за другим и иметь возможность оставлять комментарии / ответы там, где он чувствует необходимость. Прежде чем мы начнем реализовывать боковую панель, есть одна незавершенная задача, о которой мы позаботимся ниже.
Инициализация состояния отдачи комментариев
Когда документ загружается в редактор, нам нужно отсканировать документ, чтобы найти все потоки комментариев и добавить их к атомам отдачи, которые мы создали выше как часть процесса инициализации. Давайте напишем в EditorCommentUtils
служебную функцию, которая сканирует текстовые узлы, находит все ветки комментариев и добавляет их в атом 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", }) ); }
Синхронизация с внутренним хранилищем и соображения производительности
В контексте статьи, поскольку мы сосредоточены исключительно на реализации пользовательского интерфейса, мы просто инициализируем их некоторыми данными, которые позволяют нам подтвердить, что код инициализации работает.
При реальном использовании системы комментариев ветки комментариев, скорее всего, будут храниться отдельно от самого содержимого документа. В таком случае приведенный выше код необходимо будет обновить, чтобы сделать вызов API, который извлекает все метаданные и комментарии ко всем идентификаторам потока комментариев в commentThreads
. Как только ветки комментариев загружены, они, вероятно, будут обновляться, поскольку несколько пользователей добавляют к ним новые комментарии в режиме реального времени, изменяют их статус и так далее. Производственная версия системы комментариев должна структурировать хранилище Recoil таким образом, чтобы мы могли продолжать синхронизировать его с сервером. Если вы решите использовать Recoil для управления состоянием, в API Atom Effects есть несколько примеров (экспериментальных на момент написания этой статьи), которые делают что-то подобное.
Если документ очень длинный и над ним работает много пользователей во множестве веток комментариев, нам, возможно, придется оптимизировать код инициализации, чтобы загружать ветки комментариев только для первых нескольких страниц документа. В качестве альтернативы мы можем выбрать загрузку только облегченных метаданных всех веток комментариев вместо всего списка комментариев, который, вероятно, является более тяжелой частью полезной нагрузки.
Теперь давайте перейдем к вызову этой функции, когда компонент Editor
монтируется с документом, поэтому состояние отдачи правильно инициализируется.
# 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 ( <> ... </> ); }
Мы используем тот же пользовательский хук — useAddCommentThreadToState
, который мы использовали с реализацией кнопки комментариев на панели инструментов, чтобы добавлять новые потоки комментариев. Поскольку у нас работает всплывающее окно, мы можем щелкнуть одну из уже существующих веток комментариев в документе и убедиться, что она показывает данные, которые мы использовали для инициализации ветки выше.
Теперь, когда наше состояние правильно инициализировано, мы можем приступить к реализации боковой панели. Все наши потоки комментариев в пользовательском интерфейсе хранятся в семействе атомов Recoil — commentThreadsState
. Как подчеркивалось ранее, способ, которым мы перебираем все элементы в семействе атомов Recoil, заключается в отслеживании ключей/идентификаторов атома в другом атоме. Мы делали это с commentThreadIDsState
. Давайте добавим компонент CommentSidebar
, который перебирает набор идентификаторов в этом атоме и отображает компонент CommentThread
для каждого.
# 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> ); }
Теперь мы реализуем компонент CommentThread
, который прослушивает атом Recoil в семействе, соответствующем потоку комментариев, который он отображает. Таким образом, по мере того, как пользователь добавляет комментарии к цепочке в редакторе или изменяет любые другие метаданные, мы можем обновить боковую панель, чтобы отразить это.
Поскольку боковая панель может стать очень большой для документа с большим количеством комментариев, мы скрываем все комментарии, кроме первого, при отображении боковой панели. Пользователь может использовать кнопку «Показать/скрыть ответы», чтобы показать/скрыть всю ветку комментариев.
# 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> ); }
Мы повторно использовали компонент CommentRow
из всплывающего окна, хотя мы добавили обработку дизайна с использованием реквизита showConnector
, который в основном заставляет все комментарии выглядеть связанными с потоком на боковой панели.
Теперь мы визуализируем CommentSidebar
в Editor
и проверяем, что она показывает все темы, которые у нас есть в документе, и корректно обновляется по мере добавления новых тем или новых комментариев к существующим темам.
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Теперь мы переходим к реализации популярного взаимодействия с боковой панелью комментариев, которое можно найти в редакторах:
Нажатие на ветку комментариев на боковой панели должно выбрать/активировать эту ветку комментариев. Мы также добавляем дифференциальную обработку дизайна, чтобы выделить ветку комментариев на боковой панели, если она активна в редакторе. Для этого мы используем атом Recoil — activeCommentThreadIDAtom
. Давайте обновим компонент CommentThread
, чтобы он поддерживал это.
# 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> );
Если мы посмотрим внимательно, у нас есть ошибка в нашей реализации синхронизации активного потока комментариев с боковой панелью. Когда мы нажимаем на разные ветки комментариев на боковой панели, правильная ветка комментариев действительно выделяется в редакторе. Однако всплывающее окно комментариев на самом деле не переходит к измененной активной ветке комментариев. Он остается там, где он был впервые визуализирован. Если мы посмотрим на реализацию всплывающего окна комментариев, то увидим, что оно отображается на первом текстовом узле в выборе редактора. На этом этапе реализации единственным способом выбрать поток комментариев было щелкнуть текстовый узел, чтобы мы могли удобно полагаться на выбор редактора, поскольку он был обновлен Slate в результате события щелчка. В приведенном выше событии onClick
мы не обновляем выделение, а просто обновляем значение атома Recoil, в результате чего выделение Slate остается неизменным, и, следовательно, всплывающее окно комментариев не перемещается.
Решением этой проблемы является обновление выбора редактора вместе с обновлением атома отдачи, когда пользователь щелкает ветку комментариев на боковой панели. Шаги делают это:
- Найдите все текстовые узлы, на которых есть эта ветка комментариев, которую мы собираемся установить в качестве новой активной ветки.
- Отсортируйте эти текстовые узлы в том порядке, в котором они появляются в документе (для этого мы используем Slate's
Path.compare
API). - Вычислите диапазон выбора, который простирается от начала первого текстового узла до конца последнего текстового узла.
- Установите диапазон выбора в качестве нового выбора редактора (используя
Transforms.select
API Slate).
Если бы мы просто хотели исправить ошибку, мы могли бы просто найти первый текстовый узел на шаге № 1, который имеет цепочку комментариев, и установить его как выбор редактора. Тем не менее, кажется, что более чистый подход — выбрать весь диапазон комментариев, поскольку на самом деле мы выбираем цепочку комментариев.
Давайте обновим реализацию обратного вызова onClick
, включив описанные выше шаги.
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]);
Примечание : allTextNodePaths
содержит путь ко всем текстовым узлам. Мы используем API Editor.point
, чтобы получить начальную и конечную точки на этом пути. В первой статье рассматриваются концепции Slate Location. Они также хорошо задокументированы в документации Slate.
Давайте проверим, что эта реализация исправляет ошибку, и всплывающее окно комментариев правильно перемещается в активную ветку комментариев. На этот раз мы также тестируем случай с перекрывающимися потоками, чтобы убедиться, что он не сломается.
С исправлением ошибки мы включили еще одно взаимодействие с боковой панелью, которое мы еще не обсуждали. Если у нас есть очень длинный документ, и пользователь щелкает цепочку комментариев на боковой панели, которая находится за пределами области просмотра, мы хотели бы прокрутить до этой части документа, чтобы пользователь мог сосредоточиться на цепочке комментариев в редакторе. Установив выбор выше с помощью Slate API, мы получаем это бесплатно. Давайте посмотрим на это в действии ниже.
На этом мы завершаем нашу реализацию боковой панели. Ближе к концу статьи мы перечисляем некоторые приятные дополнения и улучшения функций, которые мы можем внести в боковую панель комментариев, которые помогут улучшить возможности комментирования и рецензирования в редакторе.
Разрешение и повторное открытие комментариев
В этом разделе мы сосредоточимся на том, чтобы пользователи могли помечать ветки комментариев как «Решено» или иметь возможность повторно открывать их для обсуждения, если это необходимо. С точки зрения деталей реализации, это метаданные status
в потоке комментариев, которые мы изменяем, когда пользователь выполняет это действие. С точки зрения пользователя, это очень полезная функция, поскольку она дает им возможность подтвердить, что обсуждение чего-либо в документе завершено или его необходимо возобновить, потому что есть некоторые обновления/новые точки зрения и т. д.
Чтобы включить переключение статуса, мы добавляем кнопку в CommentPopover
, которая позволяет пользователю переключаться между двумя статусами: open
и 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> ); }
Прежде чем мы проверим это, давайте также добавим боковую панель комментариев в дифференциальный дизайн для разрешенных комментариев, чтобы пользователь мог легко определить, какие потоки комментариев не разрешены или открыты, и сосредоточиться на них, если они хотят.
# 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> ); }
Заключение
В этой статье мы создали базовую инфраструктуру пользовательского интерфейса для системы комментариев в редакторе форматированного текста. Набор функций, которые мы здесь добавляем, выступает в качестве основы для создания более богатого опыта совместной работы в редакторе, где соавторы могут аннотировать части документа и обсуждать их. Добавление боковой панели комментариев дает нам возможность включить в продукт больше функций, основанных на диалогах или обзорах.
В соответствии с этим, вот некоторые функции, которые редактор форматированного текста мог бы добавить поверх того, что мы создали в этой статье:
- Поддержка
@
упоминаний, чтобы соавторы могли отмечать друг друга в комментариях; - Поддержка типов мультимедиа, таких как изображения и видео, для добавления в потоки комментариев;
- Режим предложений на уровне документа, который позволяет рецензентам вносить изменения в документ, которые отображаются как предложения по изменениям. В качестве примера можно сослаться на эту функцию в Google Docs или на отслеживание изменений в Microsoft Word;
- Усовершенствования боковой панели для поиска разговоров по ключевому слову, фильтрации тем по статусу или автору (авторам) комментариев и т. д.