Почему ContentEditable ужасен

В первый раз, когда я сидел за столом напротив Якоба (Jackob @fat) он прямо меня спросил: «Как бы ты написал текстовый редактор?».

Я нарисовал древообразную структуру на доске, взмахнул руками и сказал «это говенный редактор». Затем нарисовал колонку контейнеров со стрелками указывающими на массивы, еще немного помахал руками и сказал «это хороший редактор».

Якоб приподнял бровь.

Этот пост — то, что я ответил бы ему тогда, если бы у меня был год на раздумья.

Почему ContentEditable ужасен: математическое доказательство.

ContentEditable это нативный способ для насыщенного редактирования текста в браузере. И он — грустный.

Я попробую математически доказать вам, объясняя все на пальцах, почему текущий подход к редактированию, используемый ContentEditable не работает. Это не потому, что я считаю, что математика — наиболее убедительный способ в данном случае, а потому, что она делает аргументы более абстрактными.

Я считаю, что текстовые редакторы порождают большое количество путаницы и скверно сформулированных вопросов, вроде «Что вообще значит это What-You-See-Is-What-You-Get (WYSIWYG)?»

Так что же такое WYSIWYG? Хороший WYSIWYG редактор должен удовлетворять трем аксиомам:

  1. DOM должен быть четко сопоставим с Видимыми материалами.
  2. Выделенные элементы DOM и выделенные элементы Видимых материалов должны быть четко сопоставимы.
  3. Все видимые области редактирования должны соотносится с алгебраически замкнутым и конечным набором видимых элементов.

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

Во-вторых, я докажу, что ContentEditable не соответствует ни одной из этих аксиом.

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

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

Видимое пространство («what-you-see-is-what-you-get») это пространство всех видимых страниц — того, что ты видишь на экране, когда браузер отображает страницу. Мы считаем страницы тождественными в Видимом пространстве, если они выглядят одинаково.

Движок рендера браузера переносит страницу из пространства DOM в Видимое пространство. Подразумевается что все страницы в Видимом пространстве — результат работы функции Render(x), где x — некое DOM-дерево.

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

для всех операций редактирования E и страниц x и y в пространстве DOM
Render(x) = Render(y)   
подразумевает 
Render(E(x)) = Render(E(y))

Это способ формализации части «what you get» после «what you see». Если две страницы выглядят одинаково, и мы применяем к ним одинаковые операции редактирования, то и результат будет одинаковый 1.

Я был удивлен, как много “WYSIWYG” редакторов в веб не соответствуют этому критерию. Это может казаться очевидным, но приводит к странному экзистенциальному вопросу: что значит «одинаковый»? И это лучше рассмотреть на примерах.

Четко определенные материалы

Рассмотрим простое выражение:

Это был ладно скроенный [Хоббит][3], и звали его __*Бэггинс*__.

Редактор Medium отображает это следующим образом

Это был ладно скроенный <a href=”http://en.wikipedia.org/wiki/The_Hobbit">хоббит</a> и звали его <strong><em>Бэггинс</em></strong>.

Существует множество способов добиться отображения последнего слова полужирным курсивом 2.

<strong><em>Бэггинс</em></strong>  
<em><strong>Бэггинс</strong></em>  
<em><strong>Бэгг</strong><strong>инс</strong></em>  
<em><strong>Бэгг</strong></em><strong><em>инс</em></strong>

Все эти формы должны быть эквивалентны. Любые правки, которые вы вносите в этот текст, должны приводить к идентичным результатам в каждом из указанных случаев. И это на удивление трудно — редактировать текст с учетом всех этих DOM-форм.

В многих имплементациях ContentEditable в веб в HTML могут попасть пустой span или невидимый символ и в результате два ContentEditable-элемента ведут себя совершенно по-разному (хотя и выглядят идентично). Это не только сводит с ума, но, кроме того, ещё и затрудняет отладку.

Даже если мы знаем как реализовать четкую операцию редактирования, как мы её проверим? Если ограничить HTML простыми тегами, доказать, что две формы визуально совпадают … сложно. Лучший вариант — перебрать каждую букву, присвоить ей стиль и сравнить результат.

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

Четкое выделение материалов

Отношение между пространством DOM и Видимым пространством ужасно, но, хотя бы, это отношение типа много-к-одному. Одно дерево DOM отображается в Видимое пространство одним конкретным образом.

С выделением все намного хуже, так как это отношение типа многие-ко-многим.

Достаточно просто убедиться, что одно выделение может иметь множество соответствующих ему деревьев в пространстве DOM. Если у вас есть HTML

и звали его <strong><em>Бэггинс</em></strong>

то курсор, находящийся перед «Бэггинс» может находится в одной из трех позиций: перед тегом strong, между тегами strong и em и после тега em. Если вы поместите курсор перед «Бэггинс» и начнете вводить текст, то он будет полужирным, курсивом или ни тем, ни другим?

Скриншот

Более того, одно выделение DOM может иметь множество вариантов отображение в Видимое пространство. Рассмотрите случай, когда в «ладно скроенный» после «ладно» появится разрыв строки, как на картинке выше. Курсор в конце первой строки и в начале второй строки обладает одной и той же позицией в DOM, но находится в разных позициях в Видимом пространстве. И, насколько я понимаю, невозможно предсказать какой вариант мы увидим в браузере.

Когда мы разрабатываем команды для редактора мы хотим, чтобы выделение выглядело одинаково и одинаково себя вело. Но, так как отображение из пространства DOM в Видимое пространство нечеткое — это попаболь.

Закрытый и завершенный редактор

Пару лет назад моя подруга Джулия (Julie) отправила мне сообщение в Gchat:

Мы наконец можем отказаться от Apple Style Span … ох, счастливый день настал!

Рьюсуке Нива (Ryosuke Niwa) написал чудесный пост в блоге WebKit о том, как они избавлялись от apple-style-span. Если вы его прочтете, то многие затронутые в нем проблемы покажутся вам знакомыми. ContentEditable в WebKit добавлял массу HTML-оберток, которые ничего не меняли визуально, но заставляли редактор изменять свое поведение.

Кроме того, он обратил внимание на то, что имплементация ContentEditable в WebKit должна уметь справляться с HTML, созданным в другой CMS или любой другой имплементацией ContentEditable. Наш редактор должен хорошо уживаться в этой экосистеме. Это значит, что мы должны генерировать HTML, который легко читать и понимать. И, с другой стороны, мы должны понимать, что наш редактор должен работать с контентом вставленным из буфера, который, в нашем редакторе вообще невозможно получить.

Я сталкивался с багами, которые вообще можно было воспроизвести только создав текст в Firefox, переключившись для редактирование в Chrome и затем снова в Firefox. Это вгоняет в депрессию — и разработчиков, и пользователей.

Чтобы избежать этого класса ошибок, мы считаем, что контент хорошего WYSIWYG редактора должен быть алгебраически замкнут после его редактирования. Это значит, что контентом редактора должно быть что-то, что я мог бы написать сам, не используя редактор. И оно не должно ломаться при вставке в него HTML или редактировании в другом браузере.

Фреймворк для хороших WYSIWYG-редакторов

Голый элемент с атрибутом ContentEditable это плохой WYSIWYG-редактор, так как он не соответствует ни одной из приведенных выше аксиом. Так как нам сделать хороший?

У редактора Medium есть 4 ключевых составляющих.

  1. Создать модель документа, с простым способом определить эквивалентны ли две модели визуально
  2. Создать соответствие между DOM и нашей моделью
  3. Задать операции редактирования модели с четким поведением
  4. Преобразовывать любое нажатие клавиши или кнопки мыши в последовательность таких операций

Я кратко рассмотрю каждую из этих составляющих, и как мы их можем реализовать. После этого я рассмотрю как разработчики браузеров делают ContentEditable лучше, и могут сделать некоторые из этих составляющих ненужными.

Модель редактора Medium

Модель редактора Medium состоит из двух частей: список параграфов и список разделов.

Каждый параграф содержит:

Раздел содержит данные списка параграфов.

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

Достоинства такой модели в том, что две модели обладают одинаковым визуальным представлением тогда и только тогда, когда модели одинаковы. Любое изменение модели приводит к четко заданному изменению представления.

Соответствия в редакторе Medium

Далее, определяем соответствие пространства DOM пространству модели. Мы рассматриваем два различных случая: «внутреннее» и «внешнее» соответствие.

Внутреннее соответствие это когда мы берем контент внутри редактора и преобразуем его туда-сюда между DOM и моделью. Внутреннее соответствие должно быть один-к-одному.

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

Когда мы создаем соответствие нашей модели DOM, дерево выглядит так:

<div> <!-- Корневой элемент -->  
<section> <!-- Раздел -->  
<!-- Внутренности раздела -->  
<div class="section-inner layout-column">  
<p>  <!-- Параграф -->  
<strong><em>Baggins</em></strong> <!-- Текст -->

Узел section генерируется из модели раздела и применяет фоновые изображения или цвета к списку параграфов.

Узел section-inner генерируется на основе лаяута параграфа и определяет ширину основной колонки. Для большинства параграфов он узкий и центрированный. Для параграфов с изображениями на всю ширину, ширина 100%. Для строчной сетки лаяут обладает смещением уменьшенным вдвое.

Следующий узел это один из семантических типов параграфа: P, H2, H3, PRE, FIGURE, BLOCKQUOTE, OL-LI (упорядоченный список элементов), и UL-LI (неупорядоченный список элементов).

Когда нужно преобразовать области разметки в узлы DOM, мы сортируем их по типу: А, затем STRONG, затем EM. Мы никогда не сгенерируем якорь внутри тега STRONG. Мы преобразуем разметку так, чтобы якорь содержал тег STRONG.

Операции редактирования в редакторе Medium

У редактора Medium ровно 6 операций редактирования: InsertParagraph (ВставитьПараграф), RemoveParagraph (УдалитьПараграф), UpdateParagraph (ОбновитьПараграф), InsertSection (ВставитьРаздел), RemoveSection(УдалитьРаздел), и UpdateSection(ОбновитьРаздел).

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

Весь возможный контент редактора можно получить с помощью последовательности этих операций и обычно создать такую последовательность — тривиальная задача.

Вполне очевидно, что контент в рамках этих операций четко определен. Они работают непосредственно с моделью, а не с DOM, поэтому легко определить когда два элемента выглядят визуально одинаково.

Отслеживание редактирования

Когда вы работаете с редактором Medium нам нужно преобразовывать все нажатия кнопок и клики в последовательность указанных выше 6 операций.

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

Ключевым тут является понимание того, что мы можем перечислить все способы вставить и удалить параграф с помощью обычных ContentEditable команд. Это: возврат каретки (enter, ctrl-m и т.д.), удаление (delete, backspace и т.д.), замена текста (выделите текст и начните набирать другой) и вставка. Так что мы перехватываем эти события, отменяем их обработчики и вручную преобразовываем события клавиатуры в операции редактора.

Для всех остальных событий клавиатуры мы оставляем поведение ContentEditable по умолчанию. После завершения события клавиатуры, мы получаем модель параграфа из DOM и сравниваем с моделью, которая у параграфа была ранее. Если DOM изменился, мы создаем новую операцию UpdateParagraph и передаем её в поток событий редактора, синхронизируя DOM и модель.

Быстрый перехват редактирования

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

Но в реальном мире, перерисовывание всего поста после каждого нажатия происходит слишком медленно. И вы бы видели массу уродливого мигания элементов, так как iframe и изображения постоянно бы перезагружались. Вместо этого мы отслеживаем изменения в модели и пытаемся сделать минимально возможные изменения в DOM.

Сейчас, набирая текст, я вижу как мерцает красное подчеркивание модуля проверки правописания Chrome под словом «нажатие». Причина этого в том, что редактор Medium заменяет весь параграф сразу, вместо того, чтобы изменить его часть. Если сделать более специфичное изменение в DOM, то это мерцание прекратится, но код редактора станет более сложным.

Светлое будущее текстовых редакторов

В последнее время можно было услышать ворчание некоторых контрибьюторов Chromium (Леви Веинтрауб (Levi Weintraub), Джулии Пэрент (Julie Parent), и Елта Либренда (Jelte Liebrand)), что они хотят переписать ContentEditable с использованием кастомных элементов Polymer и теневого DOM. Предложение столкнулось с множеством тех же архитектурных проблем, которые пытается решить редактор Medium.

  1. Создать модель редактора используя кастомные элементы Polymer
  2. Определить соответствие между моделью редактора и реальным DOM используя теневой DOM
  3. Все нажатия клавиш и кнопок мыши в ContentEditable блоке будут преобразовываться в абстрактное описание операции редактирования, в виде JSON объекта вроде {editIntent: ‘delete’}
  4. Для polymer-элементов задаются обработчики для абстрактных операций редактирования

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

Каким бы мог быть ContentEditable

Когда я пытаюсь объяснить это людям, которые работают над текстовыми редакторами, меня называют шулером.

Конечно Medium лучше, чем ContentEditable. Ты жульничаешь. ContentEditable должен выполнять функции WYSIWYG HTML-редактора общего назначения. Medium отбрасывает это требование и вы можете выбирать с какими HTML-структурами работать.

Это правда. Вся беда в неправильно поставленной задаче, что приводит к недопониманию.

Хороший WYSIWYG не совместим с хорошим HTML редактором общего назначения. Это аксиома. Невозможно создать то, чем пытается быть ContentEditable, потому, что требования к WYSIWYG и HTML редактору общего назначения противоречат друг другу.

На мою точку зрения на этот вопрос сильно повлияло эссе Стива Еггэ (Steve Yegge) «Непонятная фигня». Проблемы дизайна и UX могут быть так же не решаемы, как проблемы алгоритмов big-O. Хороший WYSIWYG-редактор обычного HTML так же невозможен, как решение проблемы останова.

ContentEditable можно спасти. Но его задачи должны измениться. С более богатым DOM API, таким как API теневого DOM, ContentEditable может стать платформой для создания нового поколения редакторов в веб. Но мы должны рассматривать его как платформу и API, а не как самостоятельный компонент, который все делает сам.


Примечания

1. Если вы изучали основы высшей математики, то понимаете, что четкое соответствие это, по сути, морфизм. Это неуклюжее слово, несущее за собой подробности, которые нам не нужны. Так что в этой статье мы будем называть его «четким соответствием».

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