Мы разрабатываем веб‑приложение, позволяющее обсуждать задачи в реальном времени и поддерживающее совместное редактирование сообщений. Мы используем React, ProseMirror и AWS AppSync.
В этой статье мы расскажем о нашем использовании ProseMirror для создания редактора сообщений. ProseMirror предоставляет инструменты для создания WYSIWYG‑редактора текста в веб‑интерфейсе. Мы рассмотрим, те возможности, которые использовали сами: как создавать в ProseMirror свои простые типы узлов (для приложенных файлов и изображений), что такое транзакции в ProseMirror, и как создать узел с более сложной логикой — с динамическим изменением содержимого по подписке GraphQL. В будущих статьях мы также расскажем о реализации совместного редактирования.
Оглавление
Общие сведения о ProseMirror
Как создать свой тип узла
Транзакции
Как мы реализовали загрузку изображений
Как мы реализовали mention с помощью NodeView
Общие сведения о ProseMirror
Контейнером для редактора является div с атрибутом «contenteditable». Весь ввод в контейнер перехватывается и обрабатывается ProseMirror, после чего им же меняется содержимое контейнера.
Документ представлен как неизменяемая структура данных, которая может быть преобразована в JSON и обратно. Изменения описываются с помощью транзакций, которые также могут быть представлены как JSON и передаваться по сети. За счет этого ProseMirror «из коробки» поддерживает совместное редактирование.
Документ — это дерево узлов (Nodes), которые могут содержать дочерние узлы. Также Node может содержать массив меток — Marks — модификаторов для узлов (жирный, курсив и т. д.)
Характеристиками узла являются:
Тип (type) этого узла (абзац, список, элемент списка, заголовок, изображение и т. д.)
Дочерние узлы (content) — массив (Fragment) вложенных узлов, которые могут быть как узлами, так и просто блоками текста.
Атрибуты (attrs) — объект с дополнительными параметрами для узла. Например, id пользователя для упоминания или ссылка на изображение.
Метки (marks) — массив меток.
У документа есть схема (Schema) — описание Nodes и Marks, допустимых в документе.
Как создать свой тип узла
Теперь мы расскажем, как мы создавали свой тип узла — attachment — приложенный документ.
Для того, чтобы добавить свой тип Node, нужно описать, как преобразовать его в DOM и обратно.
Attachment содержит отображаемое имя документа и ссылку для скачивания.
Объект “attrs” описывает атрибуты этого узла. В данном случае "src" (ссылка) и "filename" (отображаемое имя).
Массив “parseDOM” содержит объекты, описывающие варианты, которыми этот Node может быть представлен в DOM, и способ получения атрибутов из этого представления. В данном случае “attachment” представлен в DOM как тег “a” с классом “attachment”. Атрибут “src” извлекается из “href”, а “filename” - это текст внутри тега “a”.
Преобразование Node в DOM описывается в методе “toDOM”. Здесь происходят действия, обратные методу “getAttrs”: тегу “a” задаются "href", "class" и внутренний текст.
Транзакции
Изменения документа описываются транзакциями. Транзакция — это описание внесенного изменения: добавление узла, удаление узла, изменение узла. Транзакция применяется к старому state и в результате получается новый state. В коде мы можем создать свою транзакцию и применить ее к state с помощью вызова метода «apply».
Содержимое транзакции можно просматривать. Для этого при создании editor передается callback «dispatchTransaction». Мы используем его, чтобы не допускать превышения максимальной длины документа, для отправки изменений на сервер (совместное редактирование) и для отправки на сервер информации об изменившемся положении выделения (чужое выделение отображается при совместном редактировании).
Когда пользователь взаимодействует с интерфейсом, ProseMirror обрабатывает события DOM, преобразуя их в транзакции, которые применяются к текущему state, создавая на его основе новый state, на основе которого формируется новый DOM.
Steps — это базовые блоки, описывающие изменения документа. Transform описывает массив из Steps.
Типы Steps:
ReplaceStep - замена части документа на Slice с другим содержимым.
ReplaceAroundStep - замена части документа на Slice с сохранением этой части, путем помещения ее в этот Slice.
AddMarkStep - добавление Mark всему содержимому между двумя позициями.
RemoveMarkStep - удаление заданного Mark у содержимого между двумя позициями.
AddNodeMarkStep - добавление Mark на Node, стоящий на указанной позициями
RemoveNodeMarkStep - удаление Mark у Node, стоящего на указанной позициями
AttrStep - изменение значения attribute у Node, стоящего на указанной позиции.
Steps не требуется создавать вручную. Для создания изменений создается пустой Transform, и на нем вызываются методы, добавляющие действия к этому Transform. Например, добавление и удаление Marks или Nodes, операции с деревом.
Индексация
Есть 2 способа обращения к частям документа: как к дереву и по offset.
Рассмотрим такой документ.
В виде дерева он будет представлен следующим образом.
А соответствующий этому дереву DOM с offsets будет таким.
Работа с документом как с деревом удобна, когда требуется рекурсивный обход документа.
Например, у нас этот подход используется при загрузке изображения. Сначала в документ добавляется preloader, а после завершения загрузки изображения он заменяется на Node с полноценным изображением. Чтобы найти preloader, который требуется заменить, мы рекурсивно обходим дерево документа.
Как происходит загрузка изображения
Когда пользователь вставляет изображение в сообщение, мы формируем GUID для него, применяем транзакцию, вставляющую pending_attached_image с этим GUID на место курсора. Когда загрузка изображения завершается, pending_attached_image заменяется на attached_image, который содержит ссылку на изображение.
Далее, рассмотрим по шагам, как происходит этот процесс на примере, где в сообщение последовательно вставляются 2 картинки.
Здесь мы использовали 2 типа узла: «pending_attached_image» и «attached_image». Первый используется для еще не загруженного изображения, и он заменяется на второй, когда загрузка завершилась.
Также рекурсивный обход дерева мы используем при обработке вставки из буфера обмена. Например, на данный момент игнорируются внешние изображения, они удаляются из вставленного контента. Для этого мы формируем новое дерево, путем добавления в него всех вставленных Nodes, кроме изображений. Также при вставке проверяется новый размер документа, и если он превышает максимально допустимый размер документа, вставка отменяется.
Offset используется для отслеживания положения выделения в документе.
У Node есть свойство «nodeSize» — размер всей Node, и «content.size» — размер содержимого Node.
Как мы реализовали mention с помощью NodeView
Перед нами стояла задача создать узел “mention” — упоминание пользователя. Этот узел знает id пользователя, должен загрузить имя пользователя, и показывать новое имя, когда пользователь его меняет. А пока имя загружается, показывать прелоадер. Пользователь не должен иметь возможность править содержимое этого узла.
Для реализации такого поведения мы создали NodeView для "mention". NodeView расширяет обычный узел. Когда создаётся узел "mention", управление передается соответствующему NodeView, чтобы он создал DOM.
Чтобы создать узел "mention", мы описали в схеме атрибуты узла "mention": "data-guid" (GUID этого mention) и "data-user-id" (Id упоминаемого пользователя). В методе "toDOM" этого узла мы создаем тег "mention", но фактически в DOM в этот момент добавляется элемент, который мы создадим в NodeView для mention.
В NodeView мы сами создаем DOM и добавляем логику, которая позволит динамически менять этот DOM, когда обновляются данные пользователя. С помощью GraphQL мы подписываемся на обновления списка пользователей. В NodeView мы реагируем на пришедший по подписке обновлённый список пользователей. Если имя пользователя изменилось, мы обновляем имя в DOM узла.
Подписка на список пользователей в NodeView происходит не напрямую через GraphQL, а через специальный объект usersWatcher, который принимает данные из GraphQL-подписки в специальном React компоненте-контроллере и распространяет их во все существующие в данныq момент узлы mention. Чтобы избежать путаницы с обычными GraphQL-подписками, мы использовали термины watch/unwatch применительно к подписке на обновления из "mention". Подробнее об этом мы расскажем в другой статье.
Так выглядит HTML-код узла "mention" во время загрузки имени и после. "data-guid" и "data-user-id" заданы в атрибутах узла, а содержимое тега "span" задаётся в NodeView.
Код NodeView для узла "mention" выглядит так.
mention: (node, view, getPos) => {
const mentionGuid = node.attrs['data-guid'];
Создаём span.
const span = document.createElement('span');
span.innerText = 'Loading...';
span.classList.add('mention');
span.setAttribute('data-guid', mentionGuid);
span.setAttribute('data-user-id', node.attrs['data-user-id']);
const userId = parseInt(node.attrs['data-user-id']);
let destroy: (() => void) | undefined;
if (that.usersWatcher) {
const { watch, unwatch } = that.usersWatcher;
Подписываемся на обновление пользователей (watch). Переданный в watch callback будет вызываться при обновлении списка пользователей, и он будет менять текст в span.
const watchGuid = watch(users => {
const user = users.find(user => user.id === userId);
Обновляем текст в span.
span.innerText = user
? '@' + user.displayName
: User #${userId} not found;
});
Создаем callback "destroy", который будет вызван в момент уничтожения узла. В нем отписываемся (unwatch) от обновление списка пользователей.
destroy = () => {
unwatch(watchGuid);
Проверяем, по какой причине узел был уничтожен. Его могли удалить при редактировании документа, либо мог быть уничтожен редактор. В первом случае мы удалим это упоминание из БД, чтобы у упомянутого пользователя исчезло уведомление.
В ProseMirror нет возможности проверить, по какой причине уничтожается узел, поэтому мы добавили редактору флаг "isBeingDestroyed", который задаётся при уничтожении редактора.
if (!that.isBeingDestoyed) {
that.onRemoveMention(that.id, mentionGuid);
}
};
} else {
span.innerText = #${userId};
}
Возвращаем из NodeView DOM узла и callback "destroy".
return {
dom: span,
destroy,
};
},
gnemtsov
Mingun
Сомнительный функционал. Я бы, наоборот, не хотел, чтобы мои неоформленные мысли мог видеть кто-то ещё, пока я их пишу в чате.
gnemtsov
Если отбросить стеснение, это довольно сильно ускоряет общение, потому что собеседники непрерывно вовлечены в разговор. Можно уже начать обдумывать ответ, пока второй человек еще пишет вопрос. Все, как при реальном общении, где у нас тоже нет условного буфера и кнопки "Отправить"
Free_ze
А еще, это позволит перебивать собеседника даже на письме.
gnemtsov
Да, интересный момент. Но это больше про культуру общения. Тут уже кому, как привычнее. Наверное, если сказать что-то типа "Извините, вынужден вас перебить..." это не обидно. Если люди особо культурные, будут терпеливо ждать, пока человек допишет.
То есть приложение не диктует правила в общении, оно делает его максимально приближенным к реальному.
Порой перебить - это даже хорошо. Человек может долго что-то формулировать, а собеседник может снять весь вопрос за секунду, увидев о чем идет речь. Все это ускоряет общение, как в реальной жизни. Никому не хочется тратить силы на написание сообщение, если оно не имеет смысла, например.
Mingun
Дело не в стеснении. А в том, что я хочу знать, что я отправляю и кому. А то можно условный пароль от очень важного сервиса не тому отправить, просто потому что он был в буфере обмена, когда вставку делал.
gnemtsov
Ну тут да, надо быть аккуратнее, конечно, чем в традиционном чате. Но также в гугл докс, например, при совместной работе с документом.
titulusdesiderio
Это же точное описание google wave - коммуникационного сервиса, закрытого 10 лет назад