Мы разрабатываем веб‑приложение, позволяющее обсуждать задачи в реальном времени и поддерживающее совместное редактирование сообщений. Мы используем React, ProseMirror и AWS AppSync.

В этой статье мы расскажем о нашем использовании ProseMirror для создания редактора сообщений. ProseMirror предоставляет инструменты для создания WYSIWYG‑редактора текста в веб‑интерфейсе. Мы рассмотрим, те возможности, которые использовали сами: как создавать в ProseMirror свои простые типы узлов (для приложенных файлов и изображений), что такое транзакции в ProseMirror, и как создать узел с более сложной логикой — с динамическим изменением содержимого по подписке GraphQL. В будущих статьях мы также расскажем о реализации совместного редактирования.

Интерфейс приложения
Интерфейс приложения
Пример сообщения c форматированием
Пример сообщения c форматированием

Оглавление

  1. Общие сведения о ProseMirror

  2. Как создать свой тип узла

  3. Транзакции

  4. Как мы реализовали загрузку изображений

  5. Как мы реализовали 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". Подробнее об этом мы расскажем в другой статье.

Mention до загрузки имени и после
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,
                };
            },

Комментарии (8)


  1. gnemtsov
    17.05.2023 04:47

    Prosemirror - действительно мощная библиотека для создания WYSIWYG редакторов. Мы до этого использовали Quill.js и другие редакторы в разных проектах. Но именно Prosemirror отличается высокой стабильностью и возможностью легкой реализации любого функционала вплоть до collaborative editing.

    На базе Prosemirror мы с автором статьи создали приложение для управления задачами и общения в реальном времени. Пользователи видят в реальном времени все что пишут в чате. То есть в принципе отсутствует такое действие, как “отправка сообщения”. Все происходит вживую, как при обычном разговоре. 

    Также Prosemirror позволил нам довольно легко реализовать совместное редактирование в реальном времени в первых сообщениях лент, включая анимацию курсоров. Если кому интересно будет пощупать этот функционал, ссылка на проект есть у меня в профиле


    1. Mingun
      17.05.2023 04:47

      Пользователи видят в реальном времени все что пишут в чате.

      Сомнительный функционал. Я бы, наоборот, не хотел, чтобы мои неоформленные мысли мог видеть кто-то ещё, пока я их пишу в чате.


      1. gnemtsov
        17.05.2023 04:47

        Если отбросить стеснение, это довольно сильно ускоряет общение, потому что собеседники непрерывно вовлечены в разговор. Можно уже начать обдумывать ответ, пока второй человек еще пишет вопрос. Все, как при реальном общении, где у нас тоже нет условного буфера и кнопки "Отправить"


        1. Free_ze
          17.05.2023 04:47

          Можно уже начать обдумывать ответ, пока второй человек еще пишет вопрос.

          А еще, это позволит перебивать собеседника даже на письме.


          1. gnemtsov
            17.05.2023 04:47

            Да, интересный момент. Но это больше про культуру общения. Тут уже кому, как привычнее. Наверное, если сказать что-то типа "Извините, вынужден вас перебить..." это не обидно. Если люди особо культурные, будут терпеливо ждать, пока человек допишет.

            То есть приложение не диктует правила в общении, оно делает его максимально приближенным к реальному.

            Порой перебить - это даже хорошо. Человек может долго что-то формулировать, а собеседник может снять весь вопрос за секунду, увидев о чем идет речь. Все это ускоряет общение, как в реальной жизни. Никому не хочется тратить силы на написание сообщение, если оно не имеет смысла, например.


        1. Mingun
          17.05.2023 04:47

          Дело не в стеснении. А в том, что я хочу знать, что я отправляю и кому. А то можно условный пароль от очень важного сервиса не тому отправить, просто потому что он был в буфере обмена, когда вставку делал.


          1. gnemtsov
            17.05.2023 04:47

            Ну тут да, надо быть аккуратнее, конечно, чем в традиционном чате. Но также в гугл докс, например, при совместной работе с документом.


    1. titulusdesiderio
      17.05.2023 04:47

      Пользователи видят в реальном времени все что пишут в чате. То есть в принципе отсутствует такое действие, как “отправка сообщения”. Все происходит вживую, как при обычном разговоре.

      Также Prosemirror позволил нам довольно легко реализовать совместное редактирование в реальном времени в первых сообщениях лент, включая анимацию курсоров.

      Это же точное описание google wave - коммуникационного сервиса, закрытого 10 лет назад