Мы разрабатываем веб‑приложение, позволяющее обсуждать задачи в реальном времени и поддерживающее совместное редактирование сообщений. Мы используем React, ProseMirror и AWS AppSync. В этой статье мы расскажем о нашем использовании ProseMirror для создания редактора сообщений. ProseMirror предоставляет инструменты для создания WYSIWYG‑редактора текста в веб‑интерфейсе. Мы рассмотрим, те возможности, которые использовали сами: как создавать в ProseMirror свои простые типы узлов (для приложенных файлов и изображений), что такое транзакции в ProseMirror, и как создать узел с более сложной логикой — с динамическим изменением содержимого по подписке GraphQL. В будущих статьях мы также расскажем о реализации совместного редактирования. Общие сведения о ProseMirror Как создать свой тип узла Транзакции Как мы реализовали загрузку изображений Как мы реализовали mention с помощью NodeView Контейнером для редактора является 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” — упоминание пользователя. Этот узел знает 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" выглядит так. Создаём span. Подписываемся на обновление пользователей (watch). Переданный в watch callback будет вызываться при обновлении списка пользователей, и он будет менять текст в span. Обновляем текст в span. Создаем callback "destroy", который будет вызван в момент уничтожения узла. В нем отписываемся (unwatch) от обновление списка пользователей. Проверяем, по какой причине узел был уничтожен. Его могли удалить при редактировании документа, либо мог быть уничтожен редактор. В первом случае мы удалим это упоминание из БД, чтобы у упомянутого пользователя исчезло уведомление. В ProseMirror нет возможности проверить, по какой причине уничтожается узел, поэтому мы добавили редактору флаг "isBeingDestroyed", который задаётся при уничтожении редактора. Возвращаем из NodeView DOM узла и callback "destroy".