Разработка Rich Text Editor: проблемы и решения

    Текстовые редакторы, как тип программного обеспечения, появились чуть позже чем динозавры, и вероятнее всего это был вообще первый софт, с которым вы столкнулись в своей жизни, возможно кто-то даже застал 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 и, к сожалению, они не могут получить хороший результат — не потому, что они плохие разработчики, а потому, что плохие браузеры.

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

    1. как редактировать контент на веб-странице;
    2. как хранить этот контент;
    3. как быть со стилями (CSS);
    4. как расширять функциональность редактора.

    Про это мы сегодня и поговорим. Начнем по порядку.

    Как редактировать контент?


    Исторически браузеры для этого предоставляют следующие возможности.

    DesignMode

    У объекта document есть такое свойство, оно существует очень давно – наверное, еще в конце 90-х годов было реализовано в первых версиях Internet Explorer.

    document.designMode = "on"
    

    Когда это свойство переключается в режим on, то абсолютно вся страница, все содержимое body становится редактируемым. Вы можете поставить курсор в любое место и отредактировать текст.

    Если погуглить, можно увидеть восторженные комментарии 10-15 летней давности на форумах типа Античата, о том, как классно можно отредактировать любую страницу, например, сайты Microsoft, Google и так далее.

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

    Contenteditable

    Это основной браузерный API для создания редактируемых блоков. При переключении этого атрибута в значение true все, что находится внутри блока, становится редактируемым. Именно таким образом сейчас сделано большинство визуальных редакторов, и наш – не исключение.

    <div contenteditable="true">
        Этот текст можно редактировать…
    </div>
    

    Проблема в том, что каждый браузер реализует пользовательские действия в этом блоке по-разному. Простейший пример: есть блок с текстом «Hello, world», включаем contenteditable=”true”, он становится редактируемым. Далее ставим курсор за запятой после слова «Hello» и нажимаем кнопку Enter.

    После этого в Chrome и Safari появится новый <div> внутри блока, в нем неразрывный пробел и оставшееся слово «world».

    <div contenteditable="true">
        Hello, 
        <div>nbsp;world</div>
    </div>
    

    А в Firefox – перенос строки <br>.

    <div contenteditable=“true”>
        Hello, 
        <br>
        world
    </div>
    

    Таким образом, вы никак не контролируете ситуацию, потому что каждый браузер действует по-разному.

    Document.execCommand

    Это специальный метод, который позволяет к выделенному участку текста применять браузерные команды. Таких команд есть фиксированное количество, например, bold, fontSize, formatText и так далее.

    JS

    document.execCommand(‘bold’);
    document.execCommand(‘fontSize’, false, 7);
    

    HTML

    <b>Hello</b>, world
    <font size=“7”>Hello</font>, world
    

    Этот API тоже существует очень давно и он крайне нестабилен. Допустим, в том же самом примере вы выделили слово «Hello» и выполнили команду execCommand (‘bold’). Текст обернулся в тег <b>. Какие тут могут быть потенциальные проблемы? Во-первых, может быть, я хочу не <b>, а <strong> (меня СЕОшники попросили). Или, может быть, я хочу на этот тег <b> еще какой-то класс добавить и мне придется делать совершать дополнительное действие.

    Ладно, <b> еще не так страшно, а если я хочу, допустим, шрифт сделать большим заголовком? Я выполняю браузерную команду fontSize, передаю значение 7. И что же? Вставляется тег <font>, который мы уже не видели на сайтах с начала 2000-х годов, причем 7-го размера. Да, визуально это похоже на то, что требовалось, но с точки зрения кода, семантики, SEO и последующей поддержки — это кошмар.

    Резюме:

    • Разные браузеры генерируют разный HTML.
    • execCommand работает не везде и не всегда (и тоже по-разному).
    • Вы не контролируете, что происходит.


    В итоге, если использовать только стандартные API, то редактор получается неполноценным. Вы не можете использовать семантические теги и применять корректные стили, не знаете, как потом это парсить и как это повлияет на SEO.

    Есть хрестоматийная статья «Why ContentEditable is Terrible», в которой разработчики описывают, почему contenteditable плох, и что с этим делать.

    Альтернативные способы


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

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

    Второй способ, который чаще всего используется на практике почти во всех современных редакторах (и в частности, у нас) — это связка contenteditable-области и хранения состояния документа (document state) на основе тех действий, которые в этой области происходят.

    Допустим, у вас есть contenteditable область. В классическом ее использовании вы ставите курсор, пишете текст, и контент меняется, причем как мы уже говорили, меняется непрогнозируемо. В данном же случае, когда вы что-то пишите в input, сначала нужным образом меняется state, а затем input перерендривается на основании изменения состояния. По сути, это подход, который в React называется Controlled Input. Это, в свою очередь, может вызвать проблемы с производительностью при быстром наборе текста. Тогда нужно использовать гибридный вариант: ввод текстовых символов сразу отображать в DOM, а более сложные действия обрабатывать сначала самостоятельно.

    Допустим, вы поставили курсор в текстовое поле и нажали Enter. Редактор понимает, что надо вставить перенос строки, или, если у вас в этот момент был выделен не текст, а объект, то надо произвести какое-то другое действие. Таким образом, вы можете контролировать абсолютно всё: что происходит у вас в редакторе: выполнять нужные действия, отменять ненужные и пр.

    Общая схема:

    • contenteditable используется как интерфейс для отслеживания событий;
    • все события перехватываются и происходит изменение document state (кроме тех событий, которые происходят очень часто и отрицательно влияют на производительность, например, передвижение каретки курсора — их можно отслеживать отдельно и использовать debounce или throttle);
    • после изменения state подключается React и обновляет DOM-представление документа;
    • controlled input.

    В state может храниться:

    • Контент (текст, картинки, эмбеды, разделители). Структура может быть вложенной — например, наша верстка построена во многом на использовании многоколоночных сеток любой глубины вложенности.
    • Позиция курсора. Полезно запоминать, куда ставится курсор, чтобы потом его можно было возвращать на место, сохранять, сериализовать и т.д.
    • Выделение текста. Мы поговорим об этом чуть подробнее ниже.
    • UI редактора – например, какая панель сейчас открыта, что активно, что не активно, в каком режиме вы находитесь и т.д.

    Таким образом, всё это можно хранить в state, и работать с редактором как с приложением, которое этот state меняет. Например, вот редактор Draft.js от Facebook, сделанный на React:



    На скриншоте видно, как они хранят state своего редактора. Текст состоит из двух строк, а справа они представлены в виде JS-объекта. У них есть атрибуты text, у каждого свой уникальный ключ key, они могут быть стилизованы или нет, и т.д.

    Но дальше всех в этом вопросе пошел Google Docs со своим движком kix. У них была совсем нестандартная задача: не просто разработать редактор, но еще и реализовать одновременную многопользовательскую работу. Это означает синхронизацию изменений, множественные курсоры и все прочие сложные штуки, которые вообще лежат за пределами вопросов разработки редактора.



    Для этого они отслеживают все события, которые происходят на странице, в специальном Iframe’е, который вообще находится далеко-далеко за пределами экрана. На скриншоте видно, что у этого iframe координата top равна –10000px, а высота всего 1px. Он используется только для того, чтобы перехватывать события, которые происходят на странице. Как вы можете видеть, у этого iframe даже нет содержимого, только пустой контейнер с contenteditable=«true». То есть это исключительно служебный инструмент.

    Чтобы решить все поставленные задачи, разработчикам Google Docs, пришлось, по сути дела, переизобрести всю систему рендеринга текста в редактора.



    На скриншоте сверху показано выделение текста, и видно, что это не стандартное выделение операционной системы, а тоже специальный div, который называется kix-selection-overlay. У него есть background-color, opacity, размеры и координаты. То есть это специальная DOM-обертка вокруг контента, которая представляет собой выделение.



    Мало того, у них даже курсор – это div шириной 1-2px, высотой в строчку, к нему применены определенные CSS свойства, которые ему позволяют мигать каждые полсекунды. Таким образом, на странице может быть одновременно 5 курсоров, если ее редактируют 5 человек.

    Как хранить контент?


    Тут мы переходим к вопросу, в каком виде хранить состояние контента в state. Традиционно контент, который отображается на веб-страницах, хранится в виде HTML.

    У этого есть свои плюсы:

    • Такой контент легко показать в браузере, потому что браузеры отлично умеют рендерить HTML. Если же вы передадите в например, JSON, то он отобразится как JSON-дерево. Вам придется предварительно превратить его в HTML и стилизовать нужным образом.
    • HTML легко изменить без редактора. Если по какой-то причине редактор не работает, вы можете всегда подправить код так, как считаете нужным,
    • Скорее всего, сейчас контент хранится в базе данных вашего сайта именно таким образом (в виде HTML).
    • Наконец, что тоже немаловажно, другой код, например, поисковые боты или сторонние плагины, приучен работать с HTML, а не с той структурой, которую вы можете придумать.

    Но есть и минусы:

    • Во-первых, как мы уже обсуждали, разные браузеры выдают разный HTML из contenteditable-областей.
    • Такой код сложнее экспортировать в другие форматы типа Facebook Instant Articles или JSON. Это актуально, если вы хотите, чтобы ваш контент отображался не только на сайте, но и улетал в мобильное приложение, был доступен для скачивания в PDF и т.д. Придется каждый раз парсить HTML, вычищать лишнее и превращать его в тот формат, который требуется.
    • Такой контент тесно связан с оформлением при помощи таких HTML атрибутов, как style, class и т.д.

    Свой формат


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

    Декомпозиция на сущности

    Во-первых, нужно декомпозировать на сущности то, с чем вы работаете в редакторе. Скорее всего, у вас будут классические элементы любого текста:

    • заголовок;
    • параграф;
    • картинка;
    • таблица;
    • список;
    • комментарий.

    В редакторе кода будут свои сущности, в графическом редакторе – другие. Надо понять, с каким контентом вы работаете.

    Структура данных

    Далее эти сущности надо представить в виде структуры с типами и атрибутами, с которой потом можно работать. Вот абстрактный пример структуры текстового элемента:

    1: {
        id: 1,
        type: "Paragraph",
        content: "this is a text",
        style: [
            {
                range: [0,3],
                format: ["bold"]
            }
        ]
    }
    

    В этом примере видно, что есть элемент 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, но по соображениям безопасности многие действия с буфером обмена блокируются.

    Например, если пользователь сам инициировал копирование, нажал <Ctrl-C> или <Правка-Копировать>, возникает событие копирования, и в этот момент разработчик может вмешаться и выполнить определенные действия:

    • изменить содержимое буфера обмена;
    • запретить вставку;
    • изменить алгоритм вставки;

    Но при этом нельзя инициировать операцию с буфером обмена, если пользователь этого сам не хочет.

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

    Поддержка Clipboard API хорошая, но есть определенные ограничения. Предположим, вы в вашем редакторе хотите реализовать копирование не просто текста, а какого-то сложного объекта. Но событие copy не возникнет, если на странице нет выделенного текста. Для этого можно использовать алгоритм компании Trello, о котором они открыто пишут на Stack Overflow. Для пользователя этого работает так: вы наводите курсор на какую-то карточку в доске, нажимаете Ctrl-C, потом перемещаете курсор в другое место, нажимаете Ctrl-V, и эта карточка вставляется в нужное место.



    Реализовано это следующим образом:

    • Перехватывается нажатие клавиши Ctrl. Разработчики Trello отловили паттерн поведения: когда человек нажимает Ctrl, то есть большая вероятность того, что вслед за этим он нажмет <C> для копирования.
    • Создается невидимая textarea с кодом того элемента, который нужно скопировать. При нажатии Ctrl за пределами экрана создается невидимая textarea, куда помещается код того элемента, который человек хочет скопировать, и туда ставится курсор и фокус.
    • Пользователь нажимает <C>, и у него копируется код, который находится в этой textarea (потому что там стоит курсор).
    • Перехватывается событие вставки.

    Когда пользователь потом перемещает курсор в другое место и нажимает «Вставить», то перехватывается событие вставки, и при помощи JS карточка вставляется в тот список, на который наведен курсор.

    У себя мы копирование сложных объектов реализовали похожим образом.

    Composition Events

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

    В двух словах о том, как они работают: если вы хотите вставить à – символ с диакритическим знаком, в macOS вы нажимаете <Alt + ‘>, потом <a>, и они склеиваются в единый символ à.

    Это вызывает сразу несколько событий:

    • compositionstart – начало процесса композиции одного символа из нескольких;
    • compositionupdate – добавление каждой составной части этого символа;
    • compositionend – формирование финального символа.

    Так же работает и голосовой ввод: вы наговариваете текст, браузер его декодирует, превращает в строку, происходит compositionupdate. Когда вы заканчиваете говорить, происходит compositionend, и появляется финальная строка.

    Undo / Redo

    Еще одна привычная функция текстовых редакторов — undo и redo, т.е. возможность отменять свои действия. Но системный механизм нам для этого уже не подойдет, потому что, напоминаю, мы храним данные в своем собственном формате в state, а системный механизм повлияет только на те изменения, которые произошли в DOM, т.е. state не откатится обратно.

    Поэтому приходится перехватывать нажатие кнопок <Command+Z> / <Ctrl+Z> и реализовывать свой механизм Undo/Redo, который, как правило, основан на хранении снэпшотов state. Алгоритм простой: произошло какое-то действие, мы сохранили снэпшот, и потом можем к нему вернуться. Некоторые изменения имеет смысл группировать в одну запись истории. Для работы с историей существует удобный модуль redux-undo.

    Как быть с CSS?


    Итак, у вас есть формат документа и вы научились его редактировать. Но помимо структуры у него есть еще и стили, и тут тоже не все не так просто:

    • Редактор — это компонент, которые сам по себе (вне приложения) малопригоден.
    • У редактора есть свои стили (UI) – панели, кнопки, размеры и т.д.
    • Редактор живет внутри приложения, например, CMS.
    • У приложения есть свои стили.
    • Внутри редактора живет сам пост, который вы редактируете.
    • У поста тоже есть свои стили (шрифты, цвета, и т.д.) Они могут быть совсем разные. Вы можете в одном и том же редакторе верстать посты с разными фирменными стилями.

    Нам нужно все это друг от друга изолировать, при этом мы помним:

    • CSS-правила живут в глобальной области видимости;
    • Порядок применения правил определяется специфичностью;
    • При всем этом нам надо обеспечивать принцип WYSIWYG – то, что в редакторе, то и на сайте.

    Пример WordPress CSS

    На скриншоте ниже мы видим интерфейс создания поста в WordPress. Вся страница — это приложение, у него есть свои стили UI-элементов (текстовые поля, кнопки, заголовки и т.д.). Зеленая область — это плагин нашего редактора со своими UI-стилями. Внутри редактора находится пост (синий блок). Поскольку редактор находится прямо в DOM-дереве страницы, а не внутри iframe, нам нужно сделать так, чтобы стили всех этих составляющих не влияли друг на друга.



    Реальный пример CSS из кода WordPress: для всех заголовков H2, которые лежат внутри блока с id=”poststuff”, задаются определенные правила типографики и отступов. А мы помним, что селекторы, у которых указан id, имеют очень большой вес, который перебить можно только таким же селектором или еще более сильным.

    #poststuff h2 {
      font-size: 14px;
      padding: 8px 12px;
      margin: 0;
      line-height: 1.4;
    }
    

    Итак, нам нужно изолировать:

    • редактор от CMS;
    • пост от редактора и CMS;
    • CMS от редактора и поста;
    • Шаблон сайта (шапка, подвал, боковая колонка, дополнительные виджеты) от стилей поста, созданного в редакторе.

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

    3 основных способа изоляции CSS:

    1. CSS reset + БЭМ – все заресетить и применять определенную систему именования ваших классов. В данном случае это самый распространенный БЭМ, но можно заменить любую другую методологию.
    2. Положить все в iframe – это более «железобетонная» защита, но со своими недостатками.
    3. Все спрятать в 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, и просто так это уже не сделаешь. Есть и другие проблемы, типичные для фреймов — например, его размер нужно автоматически адаптировать под высоту блока с контентом внутри него.

    Shadow DOM + Custom Elements

    Пожалуй, самый продвинутый подход, который все очень ждут, – это использование Shadow DOM и Custom Elements. Тогда можно будет превратить редактор в веб-компонент, внутри у него будет shadow-root и все содержимое будет полностью изолировано.

    <my-editor>
        #shadow-root
    </my-editor>
    

    Это было бы прекрасно, но пока что нас от этого светлого будущего ограничивает то, что Shadow DOM нормально поддерживается только в нескольких браузерах.



    С Custom Elements та же самая история.



    Адаптивная верстка


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

    У себя в редакторе мы реализовали специальный режим предпросмотра (preview), в котором можно посмотреть, как пост выглядит на десктопе, а потом переключиться в режим mobile и посмотреть, как это будет смотреться на мобильных устройствах (а на широких экранах отображается оба режима одновременно). Для этого мы на лету генерируем 2 iframe с определенной шириной – один под десктоп, второй под mobile. В них подключается тот же CSS-файл, который используется потом и на сайте. Тем самым обеспечивается принцип WYSIWYG. Но из-за того, что ширина iframe ограничена, становится возможным применять мобильные правила media queries. Таким образом, можно видеть реальный результат, который потом будет на смартфоне. При каждом нажатии кнопки переключения в режим Preview мы генерируем нужный HTML и кладем его внутрь iframe, это происходит довольно быстро. У iframe отсутствует атрибут SRC, его контент программно изменяется каждый раз программно.

    Как расширять функциональность?


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

    API должен уметь:


    • получать и изменять контент в редакторе;
    • регистрировать коллбэки на события в редакторе;
    • интегрировать редактор с внешней средой (CMS, приложение).

    Система плагинов


    Если вы построили редактор по принципу immutable state, то, по сути дела, каждый плагин, если говорить в терминологии Redux, – это набор actions и reducers. Примеры плагинов:

    • проверка орфографии;
    • автотипограф;
    • интеграция с внешними сервисами (Google Drive и т.д.).

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

    • oembed и санитайзинг HTML;
    • контекстное меню;
    • горячие клавиши;
    • режим редактирования HTML и CSS-кода;
    • мета-сущности (комментарии);
    • анимации;
    • оффлайн-режим;
    • запуск и остановка редактора при помощи API.


    Будущее rich text editing


    Во-первых, грядут Web Components, которые помогут решить вопросы с изоляцией компонентов и их стилей на странице, что было бы очень кстати. Если говорить про API редактирования текста, то сейчас в разработке находится спецификация W3C Input Events, которая вводит для редактируемых областей события input и beforeinput, и позволяет получить подробную информацию о том, какое действие произошло, например:

    • Insert – вставка текста;
    • insertReplacementText – замена определенного текста при помощи автокоррекции;
    • deleteByCut – удаление;
    • formatBold – форматирование.

    Дополнительные свойства:

    • InputEvent.data (insert*, format*) дает понять, какое именно форматирование применяется.
    • InputEvent.dataTransfer (text/plain, text/html) позволяет получить контент, который меняется в режиме text/plain или text/html.
    • InputEvent.getTargetRanges()

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

    Выводы


    Как редактировать данные?


    • Используйте contenteditable для отслеживания событий. Сейчас это наилучший инструмент для создания редакторов: отслеживайте пользовательские события, меняйте state и обновляйте ваш редактор.
    • Храните состояние редактора и контента в state.
    • Не храните состояние в DOM. Это касается, наверное, любого приложения, не только редактора.

    Как хранить данные?


    • Определите сущности, с которыми вы работаете.
    • Продумайте структуру state.
    • Напишите код, который будет переводить эту структуру в те форматы, которые вам нужны.

    Как быть с CSS?


    • Редактор будет жить во внешней среде, которую вы не контролируете.
    • Заранее подумайте об изоляции CSS.
    • Кладите редактор в iframe или очищайте все, что можно.

    Расширение функциональности


    Продумайте заранее:

    • Модульную архитектуру.
    • API.
    • Возможность написания и подключения плагинов.

    Примеры хороших современных редакторов:

    • Quill (Salesforce, Telegra.ph);
    • Draft.js (Facebook);
    • Trix (Basecamp);
    • ProseMirror, CodeMirror;
    • Google Docs;
    • iCloud Pages.

    Контакты:


    » E-mail: yakovishen@setka.io
    » Telegram: t.me/yaplusplus
    » Facebook: facebook.com/yaplusplus
    » Twitter: twitter.com/yaplusplus
    » Сайт Setka: https://setka.io

    В вебе и фронтенде в частности, с одной стороны, все очень быстро развивается, фреймворки там, за которыми невозможно уследить. С другой – есть базовые вопросы, такие как в этой статье. И об этом, и о том мы будем говорить на майском фестивале конференций РИТ++ и приглашаем деятельных специалистов подавать заявки и выступать с докладами.
    Конференции Олега Бунина (Онтико) 682,02
    Конференции Олега Бунина
    Поделиться публикацией
    Комментарии 25
    • +1
      Очень интересная статья, спасибо.

      Но скажите, могут ли вашей разработкой пользоваться простые смертные?

      Не профессиональные блогеры и не контент-маркетологи, не представители профессиональных контент-команд и не агенства, издатели и бренды? И не некоммерческие организации, не независимые медиа и не благотворительные организации?
      • 0
        Да, разумеется :-)

        В частности, у нас есть плагин для WordPress, который можно установить на любой сайт под управлением этой CMS. Если есть задача прикрутить сайт к другой платформе, у нас есть гайд по кастомной интеграции.
        • 0
          Хм. Я пошел на ваш сайт, посмотрел на тарифы и ужаснулся. Нет, я понимаю, для серьезного издания эти 468 баксов в год — копейки, но не для человека, который пишет в свой блог статьи хорошо если два раза в месяц.

          Я правильно понимаю, что с плагином для вордпресса мне не потребуется никаких дополнительных плясок с бубном, кредитными картами и прочим?
          • 0
            Всё верно! Можно установить WP-плагин и пользоваться редактором без регистрации и SMS. Плясать с бубном можно только когда будете праздновать первый красиво сверстанный пост.
            • 0
              Sign up for Setka Editor Free plan to create your own post style with branded fonts, colors and other visual elements and customized grid system.

              Это простым смертным нужно? ;-)
              • 0
                Если вам будет недостаточно наших дефолтных стилей и сеток — да, можете зарегистрироваться и создавать свои собственные.
      • 0
        Спасибо, хороший ликбез. Можете что-то сказать по поводу github.com/ianstormtaylor/slate? Пока что в бете, но выглядит многообещающе.
        • 0

          Как писавший RTE на draftjs и slatejs, могу суазать, что slate для людей, поразила документация.


          Из минусов — злили примеры и плагины, они не оптимально написаны и несовместимы, фактически, как только разберёшься в библиотеке и начнёшь усложнять редактор, окажется, что половину (плагины и свой код) надо переписать. Отсутствие любых действительно сложных примеров в написании блоков.

        • 0
          Это всё конечно очень классно, но посмотрев ваш блог не увидел по факту каких либо специфичных решений. То есть `Setka` избыточна для обыденных записей.
          • 0
            Посмотрите лучше другой наш блог — Accent, там много примеров постов с нестандартной версткой.

            Но должен отметить, что одной из целей создания Setka Editor было как раз упрощение создания тех самых «обыденных» постов, которые на потоке выпускаются каждый день. Например, при выпуске новостей на The Village редакция использует «сниппеты» — заранее сохраненные и утвержденные блоки, куда остается вписать только соответствующий текст в нужные поля.

            Несколько примеров:
            www.the-village.ru/village/city/news-city/304459-german-o-zhizni-v-rossii (блок цитаты)
            www.the-village.ru/village/city/news-city/303601-ethno-park (таблица рейтинга)
            www.the-village.ru/village/food/place/303767-neko (блоки в правой колонке)

            То, что когда-то давно версталось вручную или при помощи HTML-заготовок, теперь вставляется одним кликом.
          • +1

            Сколько мучений ради того, что бы накодить "редактор" на языке, не предназначенном для программирования. По-хорошему должен быть какой-нить <input type=richtext>, реализованный в движке браузера, имеющий всё нужное и учитывающий кастомизацию, сделанную пользователем браузера. Но нет, вместо такой нужной вещи они лучше придумают какое-нить новое css-свойство для закругления углов блоков.


            Вставляется тег <font>, который мы уже не видели на сайтах с начала 2000-х годов

            А я и сейчас его использую. Если нужен именно размер шрифта как самоцель, а не как следствие семантики — то вроде бы это как раз то что нужно. Ну а для семантики, очевидно, нужна не команда fontSize, а какая-нить setClass (может быть такая даже есть) или добавлялка <h1> и подобного.

            • 0
              Не в языке дело, а в самой задаче. Я лично уже давно не верю в хороший визвиг. Расставляя теги вручную я получаю гораздо более предсказуемый результат, к тому же — почти не трогая мышку.
            • +2
              По-хорошему должен быть какой-нить input type=richtext

              По сути, это и есть contenteditable.
              • 0
                Нет, contenteditable можно только назвать тем, из чего следовало сделать такой элемент, а не забивать на него 10 лет назад, как это сделали авторы браузеров. В нём нет интерактивных кнопок для управления редактированием, из него нельзя аналогично остальных input-ам забрать содержание, его нельзя напрямую отправить с формой. А я имел ввиду именно такое, которое просто работает после вставки ОДНОГО короткого тега на нужное место и сразу с приемлемым интерфейсом.
            • 0
              еще забыли redactor от imperavi упомянуть. Правда он дорогой :)
            • 0
              Quill, Draft.js и Trix хороши, но ни в одном нет поддержки таблиц. Почему так происходит и почему при проектировании их не заложили? Как с таблицами обстоит дело в вашем редакторе?
              • +1
                Насколько я понимаю, для Quill и Trix таблицы не слишком важны, т.к. эти редакторы в первую очередь нацелены на короткие форматы вроде комментариев.

                По поводу Draft.js — я общался с одним из его разработчиков на Holy.js в декабре 2016, он прямо ответил, что таблицы сделать сложно из-за внутренних особенностей структуры данных (кажется, речь шла о сложности хранения вложенных сущностей таблица — строка — ячейка).

                У нас одна из ключевых сущностей редактора — сетка. Это больше, чем таблица; сетки используются в целом для формирования многоколоночной верстки страницы. Например, вот пример поста с 12-колоночной сеткой. Сетки могут быть любой вложенности.

                При этом обыкновенные таблицы у нас тоже появятся в одном из ближайших релизов.
                • 0
                  Да, вы правы. У Quill есть открытое issue по этому поводу.
                  github.com/quilljs/quill/issues/117
                  Сложно им в свою, достаточно плоскую, модель добавить поддержку настолько вложенного элемента, как таблица.
              • 0
                Странно, почему в качестве «первого» возможного редактора несколько раз навязчиво упоминается — MS DOS Editor, если все еще здравствующий и более функциональный vi гораздо старше?
                Опять же, текстовые редакторы появились задолго до DOS, и в некоторых уже были даже регулярные выражения.

                Вдобавок, под MS-DOS был такой себе «Слово и Дело», был Lexicon.
                Были и другие менее известные редакторы, которые не могли выдавать rich текст на экран, но могли выдавать rich текст с основными эффектами (смена шрифта, размера шрифта, наклон, подчеркивание, bold) на принтер.
                • 0
                  Согласен с вами, vi занимает почетное место среди текстовых редакторов. В докладе MS DOS Editor фигурировал один раз, а в статье просто добавили подводку с его же упоминанием.
                • 0
                  Есть еще один фреймворк для построения rich редакторов bustle/mobiledoc-kit Возможно кто слышал/работал с ним? Там тоже стейт хранится в JSON.
                  • 0
                    Есть некоторая проблема редакторов, использующих свои модели хранения данных.
                    Приведенные вами хорошие, современные редакторы полностью контролируют состояние редактируемого элемента и не дают никакой возможности работать с этим элементом посредством стороннего кода. Нет возможности извне обернуть часть текста в, предположим, span с дополнительными классами и атрибутами.
                    Это нужно, к примеру, для сторонних скриптов проверки орфографии и грамматики, которые ничего не знают о вашей модели и моделях других современных редакторов. После какого либо изменения, внесенного таким скриптом, отрабатывает ваш слушатель на MutationObserver и изменения отменяются, как не валидные для вашей модели.
                    И я не нашел у этих редакторов нормальных путей расширения модели и добавления кастомного маркапа в них. Получается замкнутая система, нет чего то общего для возможности работы с этими редакторами из вне. Нужно писать дополнительные плагины, что не представляется возможным учитывая взрывной рост числа новых, хороших редакторов.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое