Когда в Sports.ru понадобился свой WYSIWYG-редактор, мы решили сделать его на основе библиотеки ProseMirror. Одной из ключевых особенностей этого инструмента является модульность и широкие возможности кастомизации, поэтому с его помощью можно очень тонко подогнать редактор под любой проект. В частности, ProseMirror уже используют в The New York Times и The Guardian. В этой статье мы расскажем о том, как с помощью ProseMirror написать свой WYSIWYG-редактор.

Пишем простой WYSIWYG-редактор с помощью ProseMirror

Обзор ProseMirror


Автор ProseMirror – Marijn Haverbeke, который известен в сообществе фронтенд-разработчиков в первую очередь как автор популярной книги Eloquent Javascript. На момент начала нашей работы (осень 2018 года), никаких материалов по работе с этой библиотеке не было, кроме официальной документации и туториалов от автора. В пакет документации от автора входят несколько разделов, самые полезные из них — это ProseMirror Guide (описание базовых концепций) и Reference Manual (спека библиотеки). Ниже будет пересказ ключевых идей из ProseMirror Guide.

ProseMirror всегда хранит состояние документа в своей собственной структуре данных. И уже из этой структуры в рантайме генерируются соответствующие DOM-элементы на странице, с которыми взаимодействует конечный пользователь. Причем ProseMirror хранит не только текущее состояние (state), но и историю предыдущих изменений, к которым можно при необходимости откатиться. Любое изменение состояния должно происходить через транзакции (transactions), привычные манипуляции с DOM-деревом напрямую тут не сработают. Транзакция – это абстракция, которая описывает логику пошагового изменения состояния. Суть их работы напоминает отправку и исполнение экшнов в библиотеках для управления состоянием, например, Redux и Vuex.

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

  • prosemirror-model – модель документа, описывающая все компоненты документа, их свойства и действия, которые можно совершить над ними;
  • prosemirror-state – структура данных, которая описывает состояние созданного документа в определенный момент времени, включая выделенные фрагменты и транзакции для перехода от одного состояния в другое;
  • prosemirror-view – представление документа в браузере и инструменты для того, чтобы пользователь мог взаимодействовать с документом;
  • prosemirror-transform – функционал для хранения истории транзакций, с помощью которого реализованы транзакции и который позволяет откатываться до предыдущих состояний или вести совместную разработку одного документа.

Помимо этого минимального набора также полезными могут оказаться следующие модули:

  • prosemirror-commands – набор уже готовых команд для редактирования. Как правило, что-то более сложное или индивидуальное нужно писать самому, но некоторые вещи Marijn Haverbeke уже сделал за нас, например, удаление выделенного фрагмента текста;
  • prosemirror-keymap – модуль из двух методов для задания быстрых клавиш;
  • prosemirror-history – реализация хранения истории изменений работает примерно как система контроля версий, т.е. можно откатить одни изменения, при этом сохранить другие, более поздние, что имеет особенное значение при совместной разработке;
  • prosemirror-schema-list – набор команд для работы с элементами, имеющими отношение к списку (под элементом имеются в виду не DOM-элементы, а элементы контента, которые описаны в схеме нашего редактора).

Редактор с помощью ProseMirror


Начнем с создания схемы редактора. Схема в ProseMirror задает список элементов, которые в нашем документе могут быть, и их свойства. У каждого элемента есть метод toDOM, который определяет, как этот элемент будет представлен в DOM-дереве на веб-странице.

Принцип WYSIWYG реализуется именно в том, что у нас при создании схемы есть полный контроль над тем, как отображается редактируемый контент на странице, и, соответственно, мы можем задать каждому элементу такую HTML-структуру и задать стили так же, как контенту для просмотра. Ноды можно создавать и настраивать под свои требования, в частности, скорее всего вам могут понадобиться абзацы, заголовки, списки, картинки, абзацы, медиа.

Допустим, у нас каждый абзац текста должен оборачиваться в тег «p» с классом «paragraph», который определяет необходимые по макету правила стилей абзаца. Тогда схема ProseMirror в этом случае может выглядеть так:

import { Schema } from "prosemirror-model";

const mySchema = new Schema({
    nodes: {
        doc: {
           content: 'block'
        },
        text: {},
        paragraph: {
            content: 'inline',
            group: 'block',
            toDOM: function toDOM(node) {
                return ['p', {class: 'paragraph'}, 0];
            },
        },
    },
});

Для начала импортируем конструктор для создания своей схемы и передаем в него объект, описывающий nodes (далее – ноды) в будущем редакторе. Ноды – это абстракции, описывающие типы создаваемого контента. Например, с такой схемой в редакторе могут быть только ноды типов text и paragraph. Doc – это имя ноды верхнего уровня, которая будет состоять только из блочных элементов, т.е. в данном случае только из параграфов (потому что другие мы не описали).

Text – это текстовые ноды, чем-то похожие на текстовые DOM-ноды. С помощью свойства group мы можем разными способами группировать наши ноды, чтобы к ним можно было проще обращаться в коде. Группы можно задать любым удобным нам способом. В нашем случае мы разделили ноды только на блочные и инлайные. Text является инлайновым по умолчанию, поэтому это можно явно не указывать.

Совсем другое дело paragraph (абзац). Мы объявили, что абзацы состоят из инлайновых текстовых элементов, а также имеют свое представление в DOM. Абзац будет являться блочным элементом, примерно в том смысле, в каком являются блочными элементы DOM. По этой схеме абзацы на странице будут представлены в онлайн-редакторе следующим образом:

<p class="paragraph">Content of the paragraph</p>

Теперь можно создать сам редактор:

import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema } from "prosemirror-model";

const mySchema = new Schema({
    nodes: {
        doc: {
            content: 'block+'
        },
        text: {
            group: 'inline',
            inline: true
        },
        paragraph: {
            content: 'inline*',
            group: 'block',
            toDOM: function toDOM(node) {
                return ['p', {class: 'paragraph'}, 0];
            },
        },
    },
});

/**
 * @classdesc объект редактора
 * @param {Object} el DOM-нода, в которую помещается редактор
 */
export default class Wysiwyg {
    constructor(el) {
        this.el = el;
    }

    /**
      * @description генерирует стейт и вью редактора
      * @param {Object} content объект, содержащий массив с элементами
      */
    setArticle(content) {
        const state = EditorState.fromJSON(
            {schema: mySchema},
            content,
        );
        const view = new EditorView(this.el, {state: state});
    }
}

В начале, как обычно, импортируем все необходимые конструкторы из соответствующих модулей и берем уже описанную выше схему. Редактор создаем в виде класса с набором необходимых методов. Для того чтобы на веб-странице появилась возможность редактировать и создавать контент, нужно создать состояние – state, – используя схему и контент статьи, и представление контента из текущего состояния в заданном корневом элементе. Мы поместили эти действия в метод setArticle, кроме которого пока ничего не нужно.

При этом контент передавать необязательно. Если его нет, то получится пустой редактор, и контент уже можно будет создать непосредственно на месте. Допустим, у нас есть HTML-файл с такой разметкой:

<div id="editor"></div>

Для создания на странице пустого WYSIWYG-редактора понадобится всего несколько строк в скрипте, который выполняется на странице с такой разметкой:

// импортируем конструктор редактора из пакета
import Wysiwyg from 'wysiwyg';

const root = document.getElementById('editor');
const myWysiwyg = new Wysiwyg(root);
myWysiwyg.setArticle();

С помощью такого кода можно написать с нуля любой текст. На этом этапе уже можно посмотреть, как работает ProseMirror.

Когда пользователь печатает текст в этом редакторе, возникают транзакции состояния. Например, если в редакторе с описанной выше схемой напечатать фразу «Это мой новый классный WYSIWYG-редактор», ProseMirror отреагирует на ввод с клавиатуры тем, что вызовет соответствующий набор транзакций, и после окончания ввода в состоянии документа обновится контент, который будет выглядет так:

content: [
    {
        type: 'paragraph',
        value: 'Это мой новый классный WYSIWYG-редактор',
    },
]

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

// импортируем конструктор редактора из пакета
import Wysiwyg from 'wysiwyg';

const root = document.getElementById('editor');
const myWysiwyg = new Wysiwyg(root);
const content = {
    type: 'doc',
    content: [
        {
            type: 'paragraph',
            value: 'Hello, world!',
        },
        {
            type: 'paragraph',
            value: 'This is my first wysiwyg-editor',
        }
    ],
};
myWysiwyg.setArticle(content);

Ура! Мы сделали наш первый редактор, который умеет создавать чистую страницу для создания нового контента и открывать для редактирования существующий.

Что делать с редактором дальше


Но даже с полным набором нод в нашем редакторе все еще отсутствуют важные функции — форматирование текста, абзацев. Для этого помимо нод в схему надо еще передать объект с настройками для форматирования – marks. Для простоты мы их так и называем – марками. А для управления добавлением, удалением и форматированием всех этих элементов пользователям нужно будет меню. Меню можно добавить с помощью кастомных плагинов, которые стилизуют объекты меню и описывают изменение состояния документа при выборе тех или иных действий.

С помощью плагинов можно создавать любые конструкции, расширяющие встроенные возможности редактора. Основная идея плагинов заключается в том, что они обрабатывают определенные действия пользователя, чтобы генерировать соответствующие им транзакции. Например, если нужно, чтобы клик по иконке списка в меню создавал новый пустой список и переводил курсор в начало первого элемента, то нам точно понадобится описать эту транзакцию в соответствующем плагине.

Подробнее про настройки форматирования и плагины можно почитать в официальной документации, также очень полезными могут быть наглядные небольшие примеры использования возможностей ProseMirror.

UPD Если вам интересно, как мы интегрировали новый редактор на основе ProseMirror в уже существующий проект, то мы рассказали об этом в другой статье.

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