Когда в Sports.ru понадобился свой WYSIWYG-редактор, мы решили сделать его на основе библиотеки ProseMirror. Одной из ключевых особенностей этого инструмента является модульность и широкие возможности кастомизации, поэтому с его помощью можно очень тонко подогнать редактор под любой проект. В частности, ProseMirror уже используют в The New York Times и The Guardian. В этой статье мы расскажем о том, как с помощью ProseMirror написать свой WYSIWYG-редактор.
Автор ProseMirror – Marijn Haverbeke, который известен в сообществе фронтенд-разработчиков в первую очередь как автор популярной книги Eloquent Javascript. На момент начала нашей работы (осень 2018 года), никаких материалов по работе с этой библиотеке не было, кроме официальной документации и туториалов от автора. В пакет документации от автора входят несколько разделов, самые полезные из них — это ProseMirror Guide (описание базовых концепций) и Reference Manual (спека библиотеки). Ниже будет пересказ ключевых идей из ProseMirror Guide.
ProseMirror всегда хранит состояние документа в своей собственной структуре данных. И уже из этой структуры в рантайме генерируются соответствующие DOM-элементы на странице, с которыми взаимодействует конечный пользователь. Причем ProseMirror хранит не только текущее состояние (state), но и историю предыдущих изменений, к которым можно при необходимости откатиться. Любое изменение состояния должно происходить через транзакции (transactions), привычные манипуляции с DOM-деревом напрямую тут не сработают. Транзакция – это абстракция, которая описывает логику пошагового изменения состояния. Суть их работы напоминает отправку и исполнение экшнов в библиотеках для управления состоянием, например, Redux и Vuex.
Сама библиотека построена на независимых модулях, которые можно отключать или добавлять в зависимости от потребностей. Список основных модулей, которые, как нам показалось, нужны будут практически всем:
Помимо этого минимального набора также полезными могут оказаться следующие модули:
Начнем с создания схемы редактора. Схема в ProseMirror задает список элементов, которые в нашем документе могут быть, и их свойства. У каждого элемента есть метод toDOM, который определяет, как этот элемент будет представлен в DOM-дереве на веб-странице.
Принцип WYSIWYG реализуется именно в том, что у нас при создании схемы есть полный контроль над тем, как отображается редактируемый контент на странице, и, соответственно, мы можем задать каждому элементу такую HTML-структуру и задать стили так же, как контенту для просмотра. Ноды можно создавать и настраивать под свои требования, в частности, скорее всего вам могут понадобиться абзацы, заголовки, списки, картинки, абзацы, медиа.
Допустим, у нас каждый абзац текста должен оборачиваться в тег «p» с классом «paragraph», который определяет необходимые по макету правила стилей абзаца. Тогда схема ProseMirror в этом случае может выглядеть так:
Для начала импортируем конструктор для создания своей схемы и передаем в него объект, описывающий nodes (далее – ноды) в будущем редакторе. Ноды – это абстракции, описывающие типы создаваемого контента. Например, с такой схемой в редакторе могут быть только ноды типов text и paragraph. Doc – это имя ноды верхнего уровня, которая будет состоять только из блочных элементов, т.е. в данном случае только из параграфов (потому что другие мы не описали).
Text – это текстовые ноды, чем-то похожие на текстовые DOM-ноды. С помощью свойства group мы можем разными способами группировать наши ноды, чтобы к ним можно было проще обращаться в коде. Группы можно задать любым удобным нам способом. В нашем случае мы разделили ноды только на блочные и инлайные. Text является инлайновым по умолчанию, поэтому это можно явно не указывать.
Совсем другое дело paragraph (абзац). Мы объявили, что абзацы состоят из инлайновых текстовых элементов, а также имеют свое представление в DOM. Абзац будет являться блочным элементом, примерно в том смысле, в каком являются блочными элементы DOM. По этой схеме абзацы на странице будут представлены в онлайн-редакторе следующим образом:
Теперь можно создать сам редактор:
В начале, как обычно, импортируем все необходимые конструкторы из соответствующих модулей и берем уже описанную выше схему. Редактор создаем в виде класса с набором необходимых методов. Для того чтобы на веб-странице появилась возможность редактировать и создавать контент, нужно создать состояние – state, – используя схему и контент статьи, и представление контента из текущего состояния в заданном корневом элементе. Мы поместили эти действия в метод setArticle, кроме которого пока ничего не нужно.
При этом контент передавать необязательно. Если его нет, то получится пустой редактор, и контент уже можно будет создать непосредственно на месте. Допустим, у нас есть HTML-файл с такой разметкой:
Для создания на странице пустого WYSIWYG-редактора понадобится всего несколько строк в скрипте, который выполняется на странице с такой разметкой:
С помощью такого кода можно написать с нуля любой текст. На этом этапе уже можно посмотреть, как работает ProseMirror.
Когда пользователь печатает текст в этом редакторе, возникают транзакции состояния. Например, если в редакторе с описанной выше схемой напечатать фразу «Это мой новый классный WYSIWYG-редактор», ProseMirror отреагирует на ввод с клавиатуры тем, что вызовет соответствующий набор транзакций, и после окончания ввода в состоянии документа обновится контент, который будет выглядет так:
Если же мы хотим, чтобы в редакторе сразу открывался для редактирования какой-нибудь текст, например, контент уже созданной ранее статьи, то необходимо, чтобы этот контент соответствовал созданной ранее схеме. Тогда код инициализации редактора будет выглядеть немного по-другому:
Ура! Мы сделали наш первый редактор, который умеет создавать чистую страницу для создания нового контента и открывать для редактирования существующий.
Но даже с полным набором нод в нашем редакторе все еще отсутствуют важные функции — форматирование текста, абзацев. Для этого помимо нод в схему надо еще передать объект с настройками для форматирования – marks. Для простоты мы их так и называем – марками. А для управления добавлением, удалением и форматированием всех этих элементов пользователям нужно будет меню. Меню можно добавить с помощью кастомных плагинов, которые стилизуют объекты меню и описывают изменение состояния документа при выборе тех или иных действий.
С помощью плагинов можно создавать любые конструкции, расширяющие встроенные возможности редактора. Основная идея плагинов заключается в том, что они обрабатывают определенные действия пользователя, чтобы генерировать соответствующие им транзакции. Например, если нужно, чтобы клик по иконке списка в меню создавал новый пустой список и переводил курсор в начало первого элемента, то нам точно понадобится описать эту транзакцию в соответствующем плагине.
Подробнее про настройки форматирования и плагины можно почитать в официальной документации, также очень полезными могут быть наглядные небольшие примеры использования возможностей ProseMirror.
UPD Если вам интересно, как мы интегрировали новый редактор на основе 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 в уже существующий проект, то мы рассказали об этом в другой статье.