Разработка Rich Text Editor: проблемы и решения05.03.2018 13:48
Текстовые редакторы, как тип программного обеспечения, появились чуть позже чем динозавры, и вероятнее всего это был вообще первый софт, с которым вы столкнулись в своей жизни, возможно кто-то даже застал MS-DOS Editor.
Однако с переходом большой части ПО в браузеры актуальны и соответствующие визуальные редакторы Rich Text Editors, и проблемных мест в их разработке масса. Если вы по какой-то причине решили сделать свой собственный редактор, то подумайте еще раз — есть мнение, что делать этого не нужно.
Чтобы вы могли принять более взвешенное решение, Егор Яковишен обобщил весь свой опыт, полученный в процессе создания Setka Editor, и рассказал про проблемы, с которыми придется столкнуться, и что можно предпринять для их решения.
Disclaimer: статья написана на основании доклада Егора на конференции Frontend Conf 2017 в июне 2017 года. Ситуация с поддержкой браузерами определенных API с тех пор уже могла измениться.
Про опыт
Компания Setka, в которой я работаю, разрабатывает инструменты для оптимизации процессов в командах, занимающихся контент-маркетингом, редакциях онлайн СМИ, а также в контентных командах брендов, запускающих свои издания. Один из наших продуктов — крутой визуальный редактор Setka Editor, позволяющий создавать вовлекающий пользователей контент действительно быстро и не теряя качества. Вот ряд возможностей редактора:
многоколоночная «журнальная» верстка;
гибкая настройка стилей под каждое издание или бренд;
адаптивная верстка под все устройства;
шаблонизация и автоматизация процессов создания статей (сниппеты, темплейты);
кастомные блоки CSS и JS-эмбеды;
live preview — возможность на лету смотреть чистовой вариант верстки.
Редактором можно пользоваться в WordPress (мы выпустили для этого специальный плагин), а также интегрировать в любую другую CMS.
Setka родилась в стенах издательского дома Look At Media, который объединяет популярные издания The Village, Furfur, Wonderzine и другие. Подробнее о предпосылках и истории разработки Setka Editor вы можете прочитать в статье из корпоративного блога Setka. Ниже пример верстки поста, который сделан в нашем редакторе.
Это могут быть как ежедневные новости, так и лонгриды, и спецпроекты.
Под спойлером еще много примеров.
Посты, сверстанные в Setka Editor только на наших изданиях ежемесячно генерят более 20 млн просмотров в месяц — просто чтобы вы понимали масштабы тех задач, с которыми нам приходится сталкиваться.
Итак, поехали!
Предыстория
Текстовые редакторы, как тип программного обеспечения, появились очень давно. Наверное, для многих это был вообще первый софт, с которым они столкнулись в своей жизни. Кто-то, может быть, застал MS-DOS Editor:
Очевидно, все видели блокнот Notepad в разных его исполнениях:
Microsoft Word тоже уже пережил много версий:
Помимо этого существуют специализированные программы, например, Adobe InDesign, для действительно сложной журнальной верстки:
Но это все классическое ПО, которое работает в операционной системе как десктопный софт.
Тем временем, в браузере ситуация другая. Во-первых, у нас есть обычная textarea. Это поле, куда можно писать текст, оно работает абсолютно везде — отличная кроссбраузерность! Но есть проблема: никакой стилизации, а уж тем более сложной верстки в этой textarea никогда не сделать, потому что это просто поле для ввода plain text. Оно лишь чуть сложнее, чем текстовый input.
Есть много продуктов, которые называют себя Rich Text Editor. Вы наверняка много раз с ними сталкивались на разных сайтах. Вот некоторые классические представители.
TinyMCE
CKEditor
Froala Editor
И еще десятки других проектов разного уровня проработки, у большинства которых есть так называемые «родовые проблемы».
Типовые проблемы Rich Text Editors
WYSIWYG
Практически во всех редакторах не соблюдается ключевой принцип WYSIWYG (What You See is What You Get). Это означает, что верстка, которую вы видите в редакторе, не обязательно будет совпадать с тем, что посетители увидят на вашем сайте. В редакторе вы работаете с маленьким окошком фиксированного размера, а на страницах сайта всё оказывается совсем иначе, т.к. там определенный шаблон сайта со своими стилями, другая ширина контентной области, дополнительные блоки и т.д. А если пользователь заходит на сайт с мобильного устройства, там уже применяются совсем другие правила.
Функций очень мало или слишком много
Функций в таких редакторах, как правило, или очень мало (bold, italic, выравнивание текста), или слишком много — 5 строчек кнопок, в которых невозможно разобраться.
Сложно «подружить» с уже существующим дизайном сайта
Дизайн уже утвержден арт-директором, сайт сверстан и работает. И теперь вам нужно как-то свой редактор подружить с этим дизайном таким образом, чтобы в нем можно было делать красивую верстку, но в рамках фирменного стиля компании. Это может оказаться довольно сложной задачей.
Кроссбраузерность
У многих редакторов есть проблемы с кроссбраузерностью — как с их интерфейсом, так и с версткой, которую они генерируют. Особенно это касатеся старых IE и Safari, но и Firefox временами преподносит сюрпризы.
Инструменты общего назначения
В большинстве случаев, Rich Text Editor — это просто инструмент для перевода визуальных блоков в HTML и CSS-код, созданный для людей, которые по разным причинам не хотят или не умеют писать код вручную. Если же вам нужен более продвинутый инструмент для решения своих задач, то часто WYSIWYG-редакторы из помощников превращаются в препятствия.
Также есть масса других проблем, например:
HTML-код в таких редакторах, как правило, проходит очень сильную очистку или, наоборот, вообще её не проходит. То есть либо вы сильно ограничены в возможностях, либо создаете потенциальную XSS-уязвимость.
Это же часто создает проблемы с SEO.
Как правило, такие редакторы позволяют верстать посты только под десктоп, и вы не знаете заранее, как ваш контент будет преобразован под mobile. Хотя, например, на наших сайтах мобильного трафика уже сейчас более 50%.
Поддержка очень старых браузеров (и масса легаси-кода вследствие этого) или, наоборот, только самых новых
И так далее. Проблем и вопросов действительно очень много.
Вы решили сделать свой редактор
Итак, наступил момент X, и вы по какой-то причине вы решили сделать свой собственный редактор.
Хорошая попытка, но не делайте этого!
Интернет просто пестрит постами, где написано, что не стоит этого делать. Cкорее всего, масса ограничений помешают вам сделать действительно хороший продукт.
В частности, это скриншот со StackOverflow, на котором один из разработчиков CKEditor (один из старых и известных редакторов) пишет, что они делают его уже 10 лет, у них до сих пор тысячи issues и, к сожалению, они не могут получить хороший результат — не потому, что они плохие разработчики, а потому, что плохие браузеры.
Тем не менее, вы все-таки отвергли все сомнения и хотите сделать свой редактор. С какими ключевыми вопросами вам предстоит столкнуться? Надо будет разобраться:
как редактировать контент на веб-странице;
как хранить этот контент;
как быть со стилями (CSS);
как расширять функциональность редактора.
Про это мы сегодня и поговорим. Начнем по порядку.
Как редактировать контент?
Исторически браузеры для этого предоставляют следующие возможности.
DesignMode
У объекта document есть такое свойство, оно существует очень давно — наверное, еще в конце 90-х годов было реализовано в первых версиях Internet Explorer.
document.designMode = "on"
Когда это свойство переключается в режим on, то абсолютно вся страница, все содержимое body становится редактируемым. Вы можете поставить курсор в любое место и отредактировать текст.
Если погуглить, можно увидеть восторженные комментарии 10–15 летней давности на форумах типа Античата, о том, как классно можно отредактировать любую страницу, например, сайты Microsoft, Google и так далее.
Этот способ несовершенен. Его основная проблема заключается в том, что чаще всего не нужно редактировать всю страницу, а только какой-то определенный блок. Наверное, единственный кейс, когда designMode применим, это когда редактируемая область лежит в iframe, и вы действительно хотите этот блок целиком сделать редактируемым. Но таких ситуаций не очень много.
Contenteditable
Это основной браузерный API для создания редактируемых блоков. При переключении этого атрибута в значение true все, что находится внутри блока, становится редактируемым. Именно таким образом сейчас сделано большинство визуальных редакторов, и наш — не исключение.
Этот текст можно редактировать…
Проблема в том, что каждый браузер реализует пользовательские действия в этом блоке по-разному. Простейший пример: есть блок с текстом «Hello, world», включаем contenteditable=«true», он становится редактируемым. Далее ставим курсор за запятой после слова «Hello» и нажимаем кнопку Enter.
После этого в Chrome и Safari появится новый
внутри блока, в нем неразрывный пробел и оставшееся слово «world».
На скриншоте видно, как они хранят state своего редактора. Текст состоит из двух строк, а справа они представлены в виде JS-объекта. У них есть атрибуты text, у каждого свой уникальный ключ key, они могут быть стилизованы или нет, и т.д.
Но дальше всех в этом вопросе пошел Google Docs со своим движком kix. У них была совсем нестандартная задача: не просто разработать редактор, но еще и реализовать одновременную многопользовательскую работу. Это означает синхронизацию изменений, множественные курсоры и все прочие сложные штуки, которые вообще лежат за пределами вопросов разработки редактора.
Для этого они отслеживают все события, которые происходят на странице, в специальном Iframe«е, который вообще находится далеко-далеко за пределами экрана. На скриншоте видно, что у этого iframe координата top равна –10000 px, а высота всего 1 px. Он используется только для того, чтобы перехватывать события, которые происходят на странице. Как вы можете видеть, у этого iframe даже нет содержимого, только пустой контейнер с contenteditable=«true». То есть это исключительно служебный инструмент.
Чтобы решить все поставленные задачи, разработчикам Google Docs, пришлось, по сути дела, переизобрести всю систему рендеринга текста в редактора.
На скриншоте сверху показано выделение текста, и видно, что это не стандартное выделение операционной системы, а тоже специальный div, который называется kix-selection-overlay. У него есть background-color, opacity, размеры и координаты. То есть это специальная DOM-обертка вокруг контента, которая представляет собой выделение.
Мало того, у них даже курсор — это div шириной 1–2 px, высотой в строчку, к нему применены определенные CSS свойства, которые ему позволяют мигать каждые полсекунды. Таким образом, на странице может быть одновременно 5 курсоров, если ее редактируют 5 человек.
Как хранить контент?
Тут мы переходим к вопросу, в каком виде хранить состояние контента в state. Традиционно контент, который отображается на веб-страницах, хранится в виде HTML.
У этого есть свои плюсы:
Такой контент легко показать в браузере, потому что браузеры отлично умеют рендерить HTML. Если же вы передадите в например, JSON, то он отобразится как JSON-дерево. Вам придется предварительно превратить его в HTML и стилизовать нужным образом.
HTML легко изменить без редактора. Если по какой-то причине редактор не работает, вы можете всегда подправить код так, как считаете нужным,
Скорее всего, сейчас контент хранится в базе данных вашего сайта именно таким образом (в виде HTML).
Наконец, что тоже немаловажно, другой код, например, поисковые боты или сторонние плагины, приучен работать с HTML, а не с той структурой, которую вы можете придумать.
Но есть и минусы:
Во-первых, как мы уже обсуждали, разные браузеры выдают разный HTML из contenteditable-областей.
Такой код сложнее экспортировать в другие форматы типа Facebook Instant Articles или JSON. Это актуально, если вы хотите, чтобы ваш контент отображался не только на сайте, но и улетал в мобильное приложение, был доступен для скачивания в PDF и т.д. Придется каждый раз парсить HTML, вычищать лишнее и превращать его в тот формат, который требуется.
Такой контент тесно связан с оформлением при помощи таких HTML атрибутов, как style, class и т.д.
Свой формат
Так или иначе, скорее всего вам потребуется свой удобный формат хранения содержимого документа.
Декомпозиция на сущности
Во-первых, нужно декомпозировать на сущности то, с чем вы работаете в редакторе. Скорее всего, у вас будут классические элементы любого текста:
заголовок;
параграф;
картинка;
таблица;
список;
комментарий.
В редакторе кода будут свои сущности, в графическом редакторе — другие. Надо понять, с каким контентом вы работаете.
Структура данных
Далее эти сущности надо представить в виде структуры с типами и атрибутами, с которой потом можно работать. Вот абстрактный пример структуры текстового элемента:
В этом примере видно, что есть элемент Paragraph, у него есть определенный content, и первые 4 буквы у него отформатированы в полужирном формате. Такую структуру можно рендерить в какие угодно представления, например:
Plain text без какого-либо оформления;
HTML;
PDF для печати и сохранения;
JSON, XML;
RSS;
Facebook Instant Articles, Google AMP, Apple News.
Что немаловажно, такую структуру можно в редакторе отображать одним образом (например, с дополнительными элементами редактирования, кнопками и т.д.), а снаружи — совсем по-другому. Таким образом, вы снова становитесь полноценным хозяином того, что у вас с контентом происходит.
Вы можете задать ожидаемый вопрос: «Послушай, у меня контент хранится в HTML много лет, у меня своя CMS. Ты предлагаешь все забыть, придумать свой формат и потом весь код переписать, чтобы с ним работать? Очевидно, что я делать это не хочу, потому что польза с точки зрения бизнеса непонятна»
У нас была та же самая проблема, когда мы внедряли редактор на наши сайты, где была большая доля legacy-кода и тысячи сверстанных постов.
Workaround:
Хранить данные в базе данных в том виде, в котором они есть уже. Скорее всего это HTML.
При загрузке в редактор парсить их и работать со своим форматом. А при сохранении данных в базу сериализовать обратно в HTML и сохранять в таком виде.
Это удобно в тех случаях, когда нужно работать в устоявшейся экосистеме (например, CMS с плагинами вроде WordPress).
Мы сейчас делаем именно так. Наш редактор работает на самых разных платформах, и мы не имеем права навязывать свой формат хранения данных. Нам нужно работать с тем, что есть.
Как работать с пользовательским вводом?
Виды пользовательского ввода в браузере:
Старая добрая клавиатура. Бывает еще и виртуальной, а также позволяет использовать горячие клавиши.
Контекстное меню.
Copy&Paste.
Drag&drop.
Голосовой ввод, который становится все более популярным.
Рукописный ввод, который актуален в азиатских странах: вы рисуете что-то, что превращается в текстовый символ, и затем попадает в текстовое поле.
Автокоррекция и автодополнение.
Ваш редактор не обязан поддерживать абсолютно все эти способы ввода. Скорее всего, голосом пока еще мало кто будет вводить длинные тексты, но, тем не менее, возможные варианты надо учитывать.
Браузерные API
Браузер для этого предлагает целый «зоопарк» различных API, потому что у contenteditable-областей, к сожалению, нет единого события change, на которое можно подписаться и точно знать, когда происходят изменения. Есть несколько ключевых API, при помощи которых можно хоть как-то работать. Хотя подчеркну, практически в каждом посте про создание визуальных редакторов — и мой рассказ не исключение — подчеркивается, что ситуация с браузерными API плохая.
Selection API позволяет работать с выделением текста на странице.
Содержит 2 полезных события: selectstart и selectionchange. Selectstart срабатывает, когда мы начинаем выделять область текста на странице, а selectionchange срабатывает каждый раз, когда область выделения меняется. Причем, что полезно, оно срабатывает не только когда выделение меняется при помощи курсора, но и когда мы нажимаем Shift + стрелки или Ctrl-A для выделения всего контента на странице.
Window.getSelection () — при помощи этого метода можно получить объект с информацией о текущем выделении текста на странице. Этот объект содержит полезные свойства, в частности anchorNode и focusNode. AnchorNode — это та нода, на которой выделение началось, а focusNode — нода, на которой выделение закончилось. У каждой из этих нод может быть свой offset, т.е. кол-во выделенных символов в этой ноде.
В качестве примера рассмотрим скриншот страницы сайта FrontendConf. Я выделил текст, начиная со слова «дизайн» и заканчивая фразой «мобильные сайты», считал текущее выделение и посмотрел, что именно выделено. Можно увидеть, что anchorNode и focusNode — обе текстовые ноды, anchorOffset=11 (слово «дизайн» начинается на 11-й позиции), а focusOffset=15. Таким образом мы можем понять, какой текст на странице сейчас выделен.
Selection API поддерживается достаточно хорошо и довольно стабилен.
Следующий API — работа с буфером обмена, Clipboard API. С ним проблем гораздо больше, везде есть какие-то звездочки и комментарии.
Clipboard API предлагает несколько событий, на которые можно подписываться: копирование контента (copy), вырезание (cut), вставка (paste), а также то, что происходит прямо перед этим: beforecopy, beforecut, beforepaste.
Некоторые из них можно самостоятельно вызвать при помощи execCommand, но по соображениям безопасности многие действия с буфером обмена блокируются.
Например, если пользователь сам инициировал копирование, нажал или <Правка-Копировать>, возникает событие копирования, и в этот момент разработчик может вмешаться и выполнить определенные действия:
изменить содержимое буфера обмена;
запретить вставку;
изменить алгоритм вставки;
Но при этом нельзя инициировать операцию с буфером обмена, если пользователь этого сам не хочет.
Причины этого понятны: допустим, у человека в буфере обмена пароль или номер кредитной карты. Если бы браузерное приложение могло самостоятельно эти данные считать и куда-нибудь отправить, то безопасность была бы никакая. Поэтому если пользователь сам не инициирует событие, мы не можем никак его вызвать сами.
Поддержка Clipboard API хорошая, но есть определенные ограничения. Предположим, вы в вашем редакторе хотите реализовать копирование не просто текста, а какого-то сложного объекта. Но событие copy не возникнет, если на странице нет выделенного текста. Для этого можно использовать алгоритм компании Trello, о котором они открыто пишут на Stack Overflow. Для пользователя этого работает так: вы наводите курсор на какую-то карточку в доске, нажимаете Ctrl-C, потом перемещаете курсор в другое место, нажимаете Ctrl-V, и эта карточка вставляется в нужное место.
Реализовано это следующим образом:
Перехватывается нажатие клавиши Ctrl. Разработчики Trello отловили паттерн поведения: когда человек нажимает Ctrl, то есть большая вероятность того, что вслед за этим он нажмет для копирования.
Создается невидимая textarea с кодом того элемента, который нужно скопировать. При нажатии Ctrl за пределами экрана создается невидимая textarea, куда помещается код того элемента, который человек хочет скопировать, и туда ставится курсор и фокус.
Пользователь нажимает , и у него копируется код, который находится в этой textarea (потому что там стоит курсор).
Перехватывается событие вставки.
Когда пользователь потом перемещает курсор в другое место и нажимает «Вставить», то перехватывается событие вставки, и при помощи JS карточка вставляется в тот список, на который наведен курсор.
У себя мы копирование сложных объектов реализовали похожим образом.
Composition Events
Есть отдельный тип событий API, с которым мы не очень часто сталкиваемся, потому что они во многом связаны с диакритическими знаками и иероглифами, которые вставляются несколькими составными действиями. Также эти события связаны с альтернативными способами ввода, например, голосовым.
В двух словах о том, как они работают: если вы хотите вставить à — символ с диакритическим знаком, в macOS вы нажимаете , потом , и они склеиваются в единый символ à.
Это вызывает сразу несколько событий:
compositionstart — начало процесса композиции одного символа из нескольких;
compositionupdate — добавление каждой составной части этого символа;
compositionend — формирование финального символа.
Так же работает и голосовой ввод: вы наговариваете текст, браузер его декодирует, превращает в строку, происходит compositionupdate. Когда вы заканчиваете говорить, происходит compositionend, и появляется финальная строка.
Undo / Redo
Еще одна привычная функция текстовых редакторов — undo и redo, т.е. возможность отменять свои действия. Но системный механизм нам для этого уже не подойдет, потому что, напоминаю, мы храним данные в своем собственном формате в state, а системный механизм повлияет только на те изменения, которые произошли в DOM, т.е. state не откатится обратно.
Поэтому приходится перехватывать нажатие кнопок / и реализовывать свой механизм Undo/Redo, который, как правило, основан на хранении снэпшотов state. Алгоритм простой: произошло какое-то действие, мы сохранили снэпшот, и потом можем к нему вернуться. Некоторые изменения имеет смысл группировать в одну запись истории. Для работы с историей существует удобный модуль redux-undo.
Как быть с CSS?
Итак, у вас есть формат документа и вы научились его редактировать. Но помимо структуры у него есть еще и стили, и тут тоже не все не так просто:
Редактор — это компонент, которые сам по себе (вне приложения) малопригоден.
У редактора есть свои стили (UI) — панели, кнопки, размеры и т.д.
Редактор живет внутри приложения, например, CMS.
У приложения есть свои стили.
Внутри редактора живет сам пост, который вы редактируете.
У поста тоже есть свои стили (шрифты, цвета, и т.д.) Они могут быть совсем разные. Вы можете в одном и том же редакторе верстать посты с разными фирменными стилями.
Нам нужно все это друг от друга изолировать, при этом мы помним:
CSS-правила живут в глобальной области видимости;
Порядок применения правил определяется специфичностью;
При всем этом нам надо обеспечивать принцип WYSIWYG — то, что в редакторе, то и на сайте.
Пример WordPress CSS
На скриншоте ниже мы видим интерфейс создания поста в WordPress. Вся страница — это приложение, у него есть свои стили UI-элементов (текстовые поля, кнопки, заголовки и т.д.). Зеленая область — это плагин нашего редактора со своими UI-стилями. Внутри редактора находится пост (синий блок). Поскольку редактор находится прямо в DOM-дереве страницы, а не внутри iframe, нам нужно сделать так, чтобы стили всех этих составляющих не влияли друг на друга.
Реальный пример CSS из кода WordPress: для всех заголовков H2, которые лежат внутри блока с id=«poststuff», задаются определенные правила типографики и отступов. А мы помним, что селекторы, у которых указан id, имеют очень большой вес, который перебить можно только таким же селектором или еще более сильным.
Шаблон сайта (шапка, подвал, боковая колонка, дополнительные виджеты) от стилей поста, созданного в редакторе.
Поскольку мы поставляем наш продукт с возможностью конфигурировать стили поста отдельно от стилей сайта, то стиль шаблона сайта и стиль поста не должны друг с другом конфликтовать.
3 основных способа изоляции CSS:
CSS reset + БЭМ — все заресетить и применять определенную систему именования ваших классов. В данном случае это самый распространенный БЭМ, но можно заменить любую другую методологию.
Положить все в iframe — это более «железобетонная» защита, но со своими недостатками.
Все спрятать в Shadow DOM и Custom Elements, но очевидный недостаток этого способа в плохой браузерной поддержке.
CSS reset + БЭМ
Допустим, мы хотим своими стилями перебить те CSS-стили WordPress, которые рассматривали выше. Единственный способ, как это можно сделать при помощи селекторов — это задать селектор с таким же весом и переопределить правило.
WordPress CSS
#poststuff h2 {
font-size: 14px;
}
CSS редактора
#my-editor h2 {
font-size: 28px;
}
Либо можно встать на скользкую дорожку инлайн-стилей и ! important, но тогда в дальнейшем у нас начнутся постоянные конфликты со специфичностью (как чужой, так и своей собственной).
К сожалению, всё это не дает 100% гарантии — на каждый хитрый селектор найдется еще более хитрый селектор, который его перебьет.
iframe
С одной стороны, положить все в iframe удобно, потому что тогда все будет точно изолировано, т. к. появляется барьер между страницей и редактором. С другой стороны, это барьер будет постоянным, а нам нужно периодически обмениваться сообщениями между внешней страницей и содержимым iframe, и просто так это уже не сделаешь. Есть и другие проблемы, типичные для фреймов — например, его размер нужно автоматически адаптировать под высоту блока с контентом внутри него.