Введение
В статье описываю подход к созданию удобного инструмента на Javascript для онлайн-редактирования текстов. В качестве примера создал прототип для редактирования статей на Хабре (описан ниже). С его помощью сейчас и вношу изменения в данную статью.
Передо мной встала задача выбора онлайн-редактора для текстов на сайте. Самым очевидным решением оказался бы один из WYSIWYG редакторов. Но этот вариант мне не понравился по нескольким причинам. Во-первых, многие уязвимости популярных CMS систем связаны именно с WYSIWYG редакторами. Во-вторых, после публикации текст часто будет отличаться от того, что было в редакторе. В-третьих, подобные редакторы сложно расширить для поддержки новых тэгов и элементов. Поэтому остановился на WYSIWYM редакторе.
Одновременно с выбором WYSIWYM редактора встал вопрос с выбором языка разметки. Стоит ли использовать Wiki или Markdown синтаксис, может быть TeX-подобный язык или даже непосредственно HTML, а для некоторых задач, возможно, будет достаточно и bbCode? После некоторых размышлений пришёл к выводу, что хранится данные могут в любом формате, но с обязательным чётким разделением содержимого и атрибутов. Это будет гарантировать, что даже изменение алгоритмов отображения не исказит информацию. Что же касается редактирования, то пользователю можно дать возможность изменения данных удобным ему способом.
Проблема
К существующим реализациям онлайн-редакторов у меня есть огромная претензия. Они неудобны, поскольку представление отделено от кода. Конечно, здесь стоит возразить, что это и есть основа WYSIWYM. Всё верно, но давайте рассмотрим конкретную ситуацию.
Предположим, что пишите статью на Хабре или ответ на форуме. Синтаксис соответствующих тегов знаком, поэтому проблем с вводом текста не возникает. Если нужно вставить изображение или иным образом выделить элемент, то можно выбрать подходящий тэг на панели инструментов или ввести вручную. Первый вариант текста готов, но для проверки форматирования перед отправкой потребуется нажать кнопку «Предпросмотр». И вот только здесь появляется не только исходный текст со всеми тэгами, но и его конкретное представление.
В ходе просмотра уже отформатированного текста находите опечатку или что-то хотите исправить и дополнить. Тут возникает проблема. Ошибочное место уже найдено в области предварительного просмотра, но для исправления необходимо вернуться в редактор, где среди множества тэгов найти тот фрагмент, который предстоит исправить.
Эта проблема хорошо заметна в wiki или со страницами CMS, когда при попытке немного скорректировать какое-нибудь предложение приходится редактировать весь документ. И чем больше документ, тем сложнее пользователю. Он уже нашёл место, которое следует исправить в наглядном представлении, но ему надо повторно пройти весь этот путь поиска, но уже в исходном коде.
Решение
Естественной выглядит возможность динамического подключения редактора для выделенного пользователем фрагмента. Нашёл и посмотрел на следующие реализации: Jeditable, jquery-in-place-editor, jQuery Plugin: In-Line Text Edit, Ajax.InPlaceEditor, EditableGrid, InlineEdit 3. Недостаток у всех один и тот же: они позволяют загрузить редактор лишь для отдельного элемента, поскольку не поддерживают форматы. Поэтому решено было сделать редактор самому и «изобрести велосипед».
Так появился jsiedit.
В ходе написания статьи наткнулся на Redactor, который позволяет включить редактор для всего текста, но там опять блок задаётся заранее, а ещё это WYSIWYG редактор. Наиболее близок к моей идее оказывается Redactor Air mode, но это лишь форматирование.
Цели и задачи jsiedit
На начальном этапе реализации были определены следующие ключевые требования:
- Пользователь должен иметь возможность редактировать отформатированный текст;
- Объём редактируемого блока должен определяться самим пользователем.
Реализация
Итак, необходимо было решить две задачи. Начнём с возможности редактирования форматированного текста. Возьмём любой набор тэгов, например, bbCode. Предположим, что изначально документ создавался именно в этом формате и на сервере хранится текст именно с bbCode тэгами. При отображении страницы пользователю на сервере происходит преобразование bbCode тэгов в соответствующие конструкции HTML.
Теперь пользователь хочет отредактировать часть уже отформатированного текста. Получается, что нам надо получить исходные bbCode тэги для выбранного фрагмента. Тут возможно два подхода. Во-первых, можно динамически (на клиенте) преобразовать HTML код в соответствующий bbCode текст. Во-вторых, можно заранее сохранить информацию о bbCode тэгах в атрибутах HTML тэгов:
<p>Обычный текст, но с <b data-bbCode="b">выделением полужирным</b> шрифтом.</p>
Ещё можно рассмотреть вариант, когда у сервера динамически запрашиваются bbCode для конкретных элементов, но это потребует уже более серьёзной доработки на стороне сервера.
При проектировании я не стал выбирать один из этих трёх вариантов, а решил воспользоваться callback функцией, чтобы разработчик сам решил, каким образом HTML представление должно быть преобразовано в необходимый формат. В примерах использовал динамическое преобразование HTML.
После завершения ввода необходимо выполнить обратное преобразование и сохранить изменения на сервере. В данном случае опять было решено воспользоваться callback функцией и дать возможность разработчику самому решить, что следует делать.
Выделение текста
Теперь рассмотрим вторую задачу — предоставление возможности пользователю самому выбрать, какой фрагмент текста он хочет отредактировать. По поводу выделения мышью есть хорошая статья Range, TextRange и Selection, поэтому сами объекты и функции Javascript описывать не буду.
Остаётся вопрос удобства со стороны пользователя. Представим, что я мышью выделил пару слов в предложении и запустил редактор. Что именно я хотел отредактировать: только эти два слова, всё предложение или весь параграф? А если я выделил слова не полностью, а лишь по несколько букв? В данном случае считаю, что следует давать возможность отредактировать весь параграф, то есть объемлющий тэг. Но ближайшим объемлющим тэгом может оказаться не <p>, а другой, например, <b>:
<p>Немного текста с <b>внутренним (!) выделением</b>.</p>
Если мы выделим "(!)", то следует отобразить редактор лишь для «внутренним (!) выделением» или для всего параграфа? Считаю, что здесь пользователю необходимо дать возможность отредактировать весь абзац, но для гибкости было решено включить callback функцию, которая для каждого DOM элемента будет сообщать, возможна ли активация редактора. Подобная функция может быть реализована примерно так:
function jsiedit_fn_sample_tag_check(elem)
{
switch (elem.tagName)
{
case 'P':
case 'DIV':
case 'SPAN':
return true;
}
return false;
}
В результате получается следующий алгоритм определения выделенного диапазона:
elem = range.commonAncestorContainer; // получим предка для выделения
while (elem && !fn_is_valid_for_edit(elem)) // используем callback функцию
{
elem = elem.parentNode; // перейдём к предку
}
Всё правильно, но тут нас может поджидать ловушка. Давайте рассмотрим следующий пример:
<div>... здесь идёт много текста ...
<p>Это первый параграф и пользователь начинает выделение, например, отсюда. Параграф заканчивается,</p>
<p>но выделение продолжается и <b>завершается лишь здесь.</b> В результате у нас выделено несколько абзацев.</p>
... а тут опять идёт много текста ...</div>
Если мы просто возьмём commonAncestorContainer, то получим <div> и отдадим пользователю на редактирование весь текст. С другой стороны, пользователь, скорее всего, хочет отредактировать лишь выделенные два параграфа. В этом случае нам надо расширить каждое выделение до полного охвата тэгов <p> и остановиться.
У объекта Range есть подходящие свойства: startContainer и endContainer. Но тут надо выровнять контейнеры до одного уровня, чтобы они оказались братьями. Получился следующий код:
var prnt = rng.commonAncestorContainer; // Это ближайший общий предок
var sc = rng.startContainer; // Это "начало" выделения
var ec = rng.endContainer; // Это "конец" выделения
if ((sc == prnt) || (ec == prnt)) // Один из граничных контейнеров является общим для всего выделения
{
sc = prnt;
ec = prnt;
}
else // Будем искать пока не станут братьями
{
while (sc.parentNode != prnt)
{
sc = sc.parentNode;
}
while (ec.parentNode != prnt)
{
ec = ec.parentNode;
}
}
К этому коду надо добавить ещё предыдущий код по проверке возможности редактирования конкретного блока. Воспользуемся методами setStartBefore и setEndAfter для создания диапазона:
var rng_new = document.createRange();
rng_new.setStartBefore(sc);
rng_new.setEndAfter(ec);
На этом задача определения выделенного блока завершается.
Отображение редактора
Следующим шагом стало отображение редактора. Он может отображаться в отдельном окне, быть зафиксирован на исходной странице, но меня интересовал вариант, когда редактор появляется на месте самого редактируемого текста. Вначале задача показалась совсем простой и был написан следующий код:
var tarea = document.createElement("textarea"); // Создаём область для редактирования
tarea.value = fn_get_text_for_range(rng_new); // Получим текст для редактирования
tarea.style.width = rng_new.startContainer.clientWidth; // Установим ширину редактора
rng_new.startContainer.parentNode.insertBefore(tarea, rng_new.startContainer); // Добавляем текстовый редактор перед выделенной областью
rng_new.deleteContents(); // Удалим редактируемый текст из представления
При выполнении этого кода реальность сильно отличалась от прогнозируемого результата. Причиной стало то, что атрибут startContainer оказался «предком» для моего выделения. Могу объяснить это тем, что новый диапазон начинается до выделенного блока. В результате решил воспользоваться вычисленными ранее переменными sc и ec.
Следующая неожиданность была связана с попыткой добавить объект до диапазона. На практике получалось, что добавляемый объект попадал в диапазон и уничтожался следующей строчкой. Чтобы избежать этого область для редактирования стал создавать после диапазона. Дополнительно решил приблизительно определять высоту для редактора. Получился следующий код:
var tarea = document.createElement("textarea"); // Создаём область для редактирования
tarea.value = fn_get_text_for_range(rng_new); // Получим текст для редактирования
tarea.style.width = ec.clientWidth + 'px'; // Установим ширину редактора
if (sc == ec)
{
tarea.style.height = Math.min(document.body.clientHeight / 2, sc.clientHeight) + 'px'; // Зададим высоту редактора
}
else
{
tarea.style.height = Math.min(document.body.clientHeight / 2, sc.clientHeight + ec.clientHeight) + 'px'; // Зададим высоту редактора
}
ec.parentNode.insertBefore(tarea, ec.nextSibling); // Добавим редактор после области
rng_new.deleteContents(); // Удалим редактируемый текст из представления
Для возможности сохранения результатов и отмены редактирования к textarea была добавлена пара кнопок.
Последним вопросом стал вызов редактора. Редактор может активироваться, например, нажатием на определённую кнопку на странице, из контекстного меню, автоматически при выделении текста с нажатым Alt и т.п. Решил добавить наиболее простой метод — это отображение кнопки рядом с местом прекращения выделения.
Для этого потребовалось добавить обработчик события mouseup. Положение для вывода кнопки определял по атрибутам pageX и pageY. Получилось примерно следующее:
function jsiedit_mouseup(event)
{
var btn = document.createElement("input");
btn.type = "button";
btn.value = "Edit";
btn.style.position = 'absolute';
btn.style.top = event.pageY + 'px';
btn.style.left = event.pageX + 'px';
btn.onclick = fn_start_editor;
document.body.appendChild(btn);
}
function jsiedit_onload()
{
document.body.addEventListener("mouseup", jsiedit_mouseup, false);
}
document.addEventListener("DOMContentLoaded", jsiedit_onload, false);
Данный код работает недостаточно корректно, поскольку создаёт по новой кнопке «Edit» при каждом отпускании кнопки мыши. Для корректировки достаточно в глобальной переменной сохранить текущее состояние и менять его в зависимости от действий пользователя.
На этом первая версия jsiedit была готова.
Редактор для Хабрахабра
В качестве демонстрации возможностей решено было создать прототип для Хабра. Необходимость в подобном редакторе ощутил при написании уже первой статьи, поскольку она получалась большой, а без предварительного просмотра искать ошибки оказалось сложно и неудобно. Ситуацию усугублял ещё запрет на изменение размеров формы ввода. В результате первоначальный вариант текста написал в блокноте. Тут можно прочитать о запуске примера, чтобы попробовать самому.
Создаваемый редактор должен был позволять редактировать любой текст в области предварительного просмотра, а после сохранения изменять как отредактированный текст, так и его исходник в поле ввода. В качестве запуска предполагалось использовать букмарклет. Собственно, именно так это всё уже и работает.
Рассмотрим проблемы, с которыми столкнулся в ходе создания редактора.
Отсутствие параграфов
Первой сложностью оказался тот факт, что в текстах Хабра отсутствуют параграфы. Вместо них при сохранении используются просто разрывы строк <br />. В результате редактируемый блок должен ограничиваться некоторыми тэгами-границами. Давайте посмотрим на следующий сгенерированный HTML код:
Начало статьи
<br>
<h4>Заголовок</h4><br>
Один из параграфов, но он не выделен в блок "P".<br>
<br>
Другой параграф, внутри которого есть <b>дополнительные <i>вложенные</i> тэги</b>. Завершается опять через тэг "BR"<br>
потом начинается следующий абзац.<br>
<br>
Продолжение статьи
Весь текст находится в одном DIV, поэтому предыдущий алгоритм вернёт на редактирование весь текст. В данном случае нам необходимо «вырезать» блок между двумя ближайшими BR тэгами. Для этого можем двигаться по свойствам previousSibling и nextSibling.
Самым сложным на данном этапе оказался выбор подхода к определению функций по проверке узлов. В итоге было принято решение, что функция будет выдавать массив логических свойств для данного узла:
- Данный узел может быть самостоятельно выбран.
- Сам узел должен быть включён в выборку.
- Данный узел может быть общим предком для выбора.
- Данный узел может быть ограничивающим узлом при выборе братьев.
Получилась следующая функция проверки:
function jsiedit_fn_sample_check_node(node)
{
switch (node.tagName)
{
case 'BR':
return [false, false, false, true];
case 'P':
return [true, true, false, true];
case 'DIV':
case 'SPAN':
return [true, false, true, true];
}
return false;
}
Если учитывать специфику предварительного просмотра на Хабре, то получим такую функцию:
function jsiedit_fn_sample_habr_check_node(node)
{
switch (node.tagName)
{
case 'BR':
return [false, false, false, true];
case 'DIV':
if (node.className == 'content html_format')
return [true, false, true, true];
}
return false;
}
С учётом новой функции был переписан блок определения границ выбранных данных:
function jsiedit_get_bounds(fn_check_node)
{
var sel = window.getSelection(); // Получим выделенный блок
if (!(typeof sel === 'undefined')) // Проверим, что что-то было выделено
{
if (sel.rangeCount == 1) // Нас не интересуют множественные выделения
{
var rng = sel.getRangeAt(0); // Получим range выделенного блока
var prnt = rng.commonAncestorContainer; // Это ближайший общий предок
var sc = rng.startContainer; // Это "начало" выделения
var ec = rng.endContainer; // Это "конец" выделения
if ((prnt.tagName == 'DIV') || (prnt.tagName == 'SPAN'))
{
if (prnt == sc)
sc = prnt.childNodes.item(rng.startOffset);
if (prnt == ec)
ec = prnt.childNodes.item(rng.endOffset);
}
var chk = fn_check_node(prnt);
var include_bounds = [true, true]; // Следует ли включить границы
if (chk && chk[2] && (sc != prnt) && (ec != prnt)) // Надо поднять границы до уровня предка, будем искать пока не станут братьями
{
while (sc.parentNode != prnt)
{
sc = sc.parentNode;
}
while (ec.parentNode != prnt)
{
ec = ec.parentNode;
}
}
else if (chk && chk[0]) // Сам предок может быть включен, добавляем
{
return [prnt, chk[1]];
}
else // Необходимо найти предка, которого получится включить
{
while (prnt.parentNode)
{
chk = fn_check_node(prnt.parentNode);
if (chk && chk[2])
{
sc = prnt;
ec = prnt;
prnt = prnt.parentNode;
break;
}
else if (chk && chk[0])
{
return [prnt.parentNode, chk[1]];
}
prnt = prnt.parentNode;
if (!prnt.parentNode)
return false;
}
}
chk = fn_check_node(sc);
if (chk && chk[0]) // Узел может быть выбран самостоятельно
{
}
else
{
while (sc.previousSibling) // Есть "младший" брат
{
sc = sc.previousSibling;
chk = fn_check_node(sc);
if (chk && chk[3])
{
include_bounds[0] = chk[1]; // Надо ли включать сам объект
break;
}
}
}
chk = fn_check_node(ec);
if (chk && chk[0]) // Узел может быть выбран самостоятельно
{
}
else
{
while (ec.nextSibling) // Есть "младший" брат
{
ec = ec.nextSibling;
chk = fn_check_node(ec);
if (chk && chk[3])
{
include_bounds[1] = chk[1]; // Надо ли включать сам объект
break;
}
}
}
return [sc, ec, include_bounds[0], include_bounds[1]];
}
}
return false;
}
В ходе проверки первой версии jsiedit иногда вместо выделенного фрагмента в редакторе оказывался весь текст. Причина этого оказалась в том, что при начале выделения на пустоте система в качестве startContainer возвращала предка, а в startOffset сохранялся дочерний элемент, с которого идёт выделение. Аналогичная ситуация и с окончанием. Поэтому пришлось воспользоваться следующим кодом:
if ((prnt.tagName == 'DIV') || (prnt.tagName == 'SPAN'))
{
if (prnt == sc)
sc = prnt.childNodes.item(rng.startOffset);
if (prnt == ec)
ec = prnt.childNodes.item(rng.endOffset);
}
Возможно, что правильнее было бы выполнять проверку по типу тэга, но для прототипа меня устроила и эта проверка.
Преобразование текста
Для работы редактора потребовались две функции преобразования. Первая должна по выделенному на странице фрагменту сформировать код на языке разметки Хабра. Это довольно простая часть, поскольку можно выполнить обход DOM и преобразовывать тэги отдельно.
Обратное преобразование можно выполнить с помощью уже существующего на Хабрахабре механизма предварительного просмотра — отправить скорректированный текст на сервер и получить обратно уже HTML код. Но я решил выполнить это преобразование на стороне клиента. Первоначально попытался найти готовый HTML парсер на Javascript. К сожалению, найденные реализации меня не устроили. Тогда понял, что потребуется писать парсер с нуля и изобретать очередной велосипед. Поскольку дело это не быстрое, то решено было отложить парсер для отдельных статей, а для прототипа найти хотя бы временное решение. В качестве самого простого подхода решено было просто добавить преобразование для отличных от стандарных тэгов. Получилась следующая функция:
jsiedit_fn_sample_habr_produce = function(src)
{
var rep = [
[/<sources+lang=/gi, '<pre><code class='],
[/</source>/gi, '</code></pre>'],
[/n/g, '<br>'],
[/<hhs+user=['"]([^'"]+)['"]s*/>/gi, '<a href="http://habrahabr.ru/users/$1/" class="user_link">$1</a>'],
[/<spoilers+title=['"]([^'"]+)['"]>/gi, '<div class="spoiler"><b class="spoiler_title">$1</b><div class="spoiler_text">'],
[/</spoiler>/gi, '</div></div>']
];
var str = src;
var i;
for (i = 0; i < rep.length; i++ )
{
str = str.replace(rep[i][0], rep[i][1]);
}
return str;
};
Вот с этой функцией потом пришлось подробно разбираться. Появилась проблема, связанная с кодами программ — тэгом <source>. Все входящие в него тэги не должны интерпретироваться, но они должны интерпретироваться вне этих блоков. Из-за этого форматирование стало «ломаться».
Одним из работающих решений оказалась автоматическая замена всех символов "<" на "<" при получении кода. Хотя это и сработало, но получающийся в редакторе код оказался слишком некрасивым. В результате приступил к доработке функции. Алгоритм выбрал следующий: найти все куски кода, которые включают код, после чего заменить внутри них все критические символы. Получилось следующее преобразование:
jsiedit_fn_sample_habr_produce = function(src)
{
var fn_source = function(s)
{
var res = s.match(/^<sources+lang=([^>]*)>([sS]*)</source>$/i);
if (res)
return '<pre><code class=' + res[1] + '>' + res[2].replace(/</g,'<').replace(/>/g,'>') + '</code></pre>';
res = s.match(/^<source>([sS]*)</source>$/i);
if (res)
return '<pre><code>' + res[1].replace(/</g,'<').replace(/>/g,'>') + '</code></pre>';
res = s.match(/^<pre>([sS]*)</pre>$/i);
if (res)
return '<pre>' + res[1].replace(/</g,'<').replace(/>/g,'>') + '</pre>';
return s.replace(/</g,'<').replace(/>/g,'>');
};
var rep = [
[/(<sources([sS])*?</source>)|(<source>([sS])*?</source>)|(<pre>([sS])*?</pre>)/gi, fn_source],
[/<anchor>([^<]*)</anchor>/gi, '<a name="$1"></a>'],
[/n/g, '<br>'],
[/<hhs+user=['"]([^'"]+)['"]s*/>/gi, '<a href="http://habrahabr.ru/users/$1/" class="user_link">$1</a>'],
[/<spoilers+title=['"]([^'"]+)['"]>/gi, '<div class="spoiler"><b class="spoiler_title">$1</b><div class="spoiler_text">'],
[/</spoiler>/gi, '</div></div>']
];
var str = src;
var i;
for (i = 0; i < rep.length; i++ )
{
str = str.replace(rep[i][0], rep[i][1]);
}
return str;
};
Вероятно, что данный код можно сделать намного симпатичнее, но сейчас редактор для Хабра создавался лишь как некоторый прототип — обоснование идеи. По мере необходимости функциональность можно улучшать и добавлять дополнительные тэги. Думаю, что регулярных выражений вполне хватит. У идеи реализации парсера приоритет понизил.
Заключение
Подводя итог, хочу подчеркнуть, что целью данной статьи было описание идеи по созданию javascript in-place WYSIWYM редактора и конкретного подхода к реализации. Буду рад, если подобный редактор заинтересует ещё кого-нибудь. Напишите, пожалуйста, ваше мнение. Может быть уже существуют более интересные решения?
Вопросы и ответы
В1: Почему не используется библиотека xxxxxxx? Почему обрабатываются не все ошибки? Почему код не работает в браузере yyyyyyy?
О1: Я постарался изложить идею in-place WYSIWYM редактора. Прототип — это proof of concept, который работает в FF18, где сейчас и пишу данные строки. Пока я был единственным заинтересованным потребителем. Если вам интересно развитие данной библиотеки, то напишите. Приведённой информации достаточно, чтобы обеспечить работу в требуемых браузерах, подключить нужную библиотеку или фреймворк.
В2: Почему букмарклет для Хабра не выделяет синтаксис при сохранении? Почему поддерживаются не все тэги?
О2: Если будет интерес, то это всё можно реализовать. Из оставшихся тэгов в первую очередь реализовал бы <video>.
В3: Какие ближайшие планы?
О3: Очень сильно будут зависеть от вашей реакции. Во-первых, надо добавить поддержку редактирования статей (сейчас не могу проверить), а не только создание новых. Во-вторых, устранение известных ошибок и расширение списка поддерживаемых тэгов. Потом, вероятнее всего, начну с Add-On для FF, чтобы избавиться от букмарклета.
В4: Где можно найти исходные тексты?
О4: Всё находится на странице проекта на github: http://praestans.github.com/jsiedit/.
В5: Как использовать jsiedit для Хабрахабра?
О5: Запустить проще всего как букмарклет (что это такое). При создании новой темы надо сделать предварительный просмотр, после чего в области предварительного просмотра будет активироваться редактор при выделении мышью. При сохранении все данные из области предварительного просмотра будут переноситься в окно ввода.
Здесь изображение с подробной инструкцией.
Это ссылка на букмарклет (её надо добавлять в закладки). Вот сам отформатированный текст букмарклета:
javascript: (function ()
{
var a = document.createElement('script');
a.type = 'text/javascript';
a.src = 'http://praestans.github.com/jsiedit/lib/habr_bmk.js';
document.getElementsByTagName('head')[0].appendChild(a);
})();
Предупреждение: сейчас поддерживаются лишь следующие тэги: A, ANCHOR, B, BLOCKQUOTE, BR, EM, H1, H2, H3, H4, H5, H6, HABRACUT, HH, HR, I, IMG, LI, OL, SOURCE, STRIKE, STRONG, SUB, SUP, TABLE, TD, TH, TR, U, UL. При использовании очередного (нового для себя) тэга с помощью предварительного просмотра проверяйте, что данный тэг корректно интерпретируется. Для некоторых вариантов использования может быть получено некорректное форматирование, а текст соответствующего элемента пропадёт.
В6: Что ещё необходимо знать?
О6: В настоящий момент есть несколько известных мне ошибок и особенностей работы:
1. При сохранении списков между происходит добавление <br> между <li> блоками, поэтому список начинает «разъезжаться». Если новый тэг <li> будет на строке окончания предыдущего, то лишних пропусков не будет.
2. После сохранения последнего параграфа, иногда, он меняется местами с предпоследним. Предполагаю, здесь необходимо разбираться с функциями работы с диапазонами и вставки в DOM.
3. Если выделить большой блок текста, то этот блок будет скрыт, а редактор окажется в самом начале блока и необходимо будет выполнить прокрутку вверх. Необходимо добавить функцию, которая обеспечивала бы видимость редактора на экране при начале редактирования.
4. Одной из особенностей реализации является то, что программа сама генерирует исходный текст разметки на основе HTML текста предварительного просмотра. Поскольку исходный авторский код недоступен, то все тэги оказываются представлены однообразно, в текущей версии — прописными буквами.
5. Как следствие того, что программа выполняет преобразование введённого текста в html, а потом обратно в код хабрахабра, при ошибках преобразования невозможно вернуть текст обратно для исправления даже минимальных ошибок. Если в «испорченный» код попали блоки <source>, то угловые скобки "<" и ">" окажутся заменёнными на "<" и ">". Но после их исправления у тэгов <source> и </source> всё должно стать опять корректно.
Автор: Boriso