Редактор форматированного текста с возможностью редактирования содержимого и классами выбора и диапазона JavaScript.

Когда я изучал веб-разработку, JavaScript не был таким мощным, как сегодня, и большая часть форматирования текста выполнялась с помощью BB-кодов. Позже я обнаружил HTML-атрибут contenteditable, который позволяет пользователю редактировать содержимое HTML-тега. Contenteditable обычно используется с функцией execCommand, которую можно использовать для динамического форматирования выделенного текста. Функция execCommand не только очень проста в использовании, поскольку для большинства параметров форматирования требуется всего одна строка кода, но также чрезвычайно мощна и поддерживается каждым браузером. Не должно быть никаких причин не использовать его, и я использовал его для различных проектов в прошлом.

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

Тем не менее, использование устаревшей функции всегда кажется неприятным и рискованным. Вместо этого я хотел научиться делать это правильно. После некоторых исследований я выяснил, что классы Selection и Range предназначены для использования в современном форматировании текста. Однако они намного сложнее и неинтуитивны в использовании по сравнению с execCommand.

Основное форматирование

Я начал с самых основных вариантов форматирования: полужирный и курсив. В обоих случаях выделение должно быть окружено простым тегом HTML без каких-либо дополнительных атрибутов, таких как <b></b> или <i></i>. Однако я также узнал, что теги <b> и <i> больше не используются, и вместо них вы должны использовать <strong> и <em>. Основное использование таких простых тегов:

var selection = window.getSelection();
var range = selection.getRangeAt( 0 );
var newNode = document.createElement( "STRONG" );
newNode.appendChild( range.extractContents() );
range.insertNode( newNode );

Это возьмет выделение и окружит его тегом <strong>. Что немного сложнее, так это снова удалить тег <strong>. Для этого мы должны выяснить, находится ли выбор внутри тега данного типа, а затем удалить этот узел из DOM. Это можно сделать с помощью range.startContainer и range.endContainer и обхода DOM, пока мы не доберемся до контейнера div contenteditable. Только проверки прямого родителя недостаточно, потому что искомый тип узла может быть вложен в другие узлы другого типа, как если бы мы искали <strong> в этом примере: “some <strong><em><u>nested</u></em></strong> text”.

#isInside( node , nodeName )
{
  if ( node === this.div ) return false;
  if ( node === document.body ) return false;
 
  if ( node.parentElement.nodeName === nodeName )
  {
    return node.parentElement;
  }
  else
  {
    return this.#isInside( node.parentElement , nodeName );
  }
}
const startContainerNode = this.#isInside( range.startContainer , "STRONG" );
const endContainerNode = this.#isInside( range.endContainer , "STRONG" );

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

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

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

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

Ссылки

После простейшего случая пустых тегов ссылки — это первый вариант форматирования, который требует дополнительной работы, потому что тегу <a> нужен атрибут href, поэтому наша ссылка действительно куда-то ведет. К счастью, для этого не потребовалось много изменений в коде форматирования, кроме добавления атрибута href к элементу при его создании. Вместо этого большинство различий заключалось в наложенном пользовательском интерфейсе. Когда пользователь нажимает кнопку форматирования ссылки, интерфейс меняется на большое поле ввода, в которое можно скопировать URL-адрес, и кнопку «ОК» для подтверждения.

Пустые строки

До сих пор все варианты форматирования были для текста. Однако другие элементы HTML стоят сами по себе. Самый популярный пример — изображения, для которых нужен только тег <img>, а не тег </img>. Добавлять такие элементы не намного сложнее, чем текстовые. Однако нет очевидного решения для отображения пользовательского интерфейса для вставки этих типов элементов. Кнопка может быть над или под редактором, но когда текст становится длинным, для доступа к нему требуется прокрутка, и это неудобно. Вместо этого я немного вдохновился редактором Medium, который показывает интерфейс, когда курсор находится в пустой строке.

Попытка выяснить, находится ли курсор в пустой строке и где он находится на экране, оказалась довольно сложной. Для обычного наложения, которое отображается при выделении текста, я использую метод range.getBoundingClientRect();, который возвращает ограничивающую рамку выделения. Однако, когда выделен только символ вставки, этот метод всегда возвращает 0,0. На мой взгляд, это ошибка или, по крайней мере, недосмотр в Range API. Ясно, что браузер знает, где находится курсор, потому что он отображает в этом месте мигающую короткую черту. Так почему же метод range.getBoundingClientRect(); не может вернуть эту позицию?

Из-за этой ошибки мне пришлось прибегнуть к очень странному обходному пути, который включает в себя эту неприятную строку кода, которая проверяет, содержит ли текст innerHTML редактора до и после знака вставки элементы с разрывом строки:

if ( ( this.div.innerHTML.indexOf( "<br>" , caretPosition-4 ) === caretPosition-4 || 
this.div.innerHTML.indexOf( "<div>" , caretPosition-5 ) === caretPosition-5 || 
this.div.innerHTML.indexOf( "<hr>" , caretPosition-4 ) === caretPosition-4 ) &&
this.div.innerHTML.indexOf( "<br>" , caretPosition ) === caretPosition )

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

Изображений

Добавление изображений в DOM довольно просто:

img( url )
{
  var selection = window.getSelection();
  var range = selection.getRangeAt( 0 );
  if ( selection.type === "Caret" )
  {
    var linkNode = document.createElement( "A" );
    linkNode.href = url;
    linkNode.target = "_blank";
    var imgNode = document.createElement( "IMG" );
    imgNode.src = url;
    linkNode.appendChild( imgNode );
    range.insertNode( linkNode );
  }
}

Сложность с изображениями заключается в их интеграции в веб-сайты, потому что изображение должно быть загружено на сервер, прежде чем его можно будет включить. Это всегда зависит от серверного решения; в редактор нельзя было включить общее решение. Редактор заходит так далеко, что при нажатии кнопки изображения создается и запускается новый скрытый ввод файла. Когда пользователь выбирает изображение, вызывается обратный вызов onImageUpload( fileInput ), который можно использовать для реализации загрузки изображения. После завершения загрузки изображения его можно вставить с помощью функции, описанной выше. Поскольку в CMS моего сайта уже была функция загрузки изображений, интеграция нового редактора прошла безболезненно.

Код

Последний вариант форматирования, который я добавил (когда я начал писать этот пост в блоге и понял, что хочу включить примеры кода), — это <code> блоков. Концептуально они похожи на другие элементы, поэтому я смог быстро их добавить. Однако при нажатии Enter внутри них Chrome всегда разбивал блоки кода по одному на строку, что выглядело очень плохо. Я имел дело с этой проблемой ранее, потому что Chrome также всегда инкапсулировал каждый новый разрыв строки в контейнере <div>. Я исправил это, просто удалив все вхождения <div> и </div>, потому что я не использовал их ни для чего другого. Однако это не сработало для блоков кода, и в любом случае это было очень хакерское решение. Я попробовал еще несколько способов решить проблему и через некоторое время наткнулся на простое, но хорошее решение в Интернете: при изменении отображения CSS элемента contenteditable div (и блоков кода) с block на inline-block Chrome не сделать странный разрыв строки больше, и все работает.

Интеграция

Интеграция нового редактора на мой веб-сайт оказалась несложной. Мне даже удалось сделать его почти полностью совместимым со старым редактором BB-кода, чтобы один и тот же пост в блоге можно было редактировать в любом редакторе. Я почти уверен, что есть некоторые детали и крайние случаи, которые я упустил, но я исправлю их, когда найду.

Отсутствующие функции

Пока что я реализовал только самые основные функции редактора, но, конечно, можно было бы сделать еще много чего:

  • Динамическое сохранение: текст должен сохраняться каждые несколько минут или если в течение нескольких секунд не было обнаружено нажатия клавиш, чтобы он не терялся при закрытии вкладки, сбое ПК и т. д.
  • Управление версиями: сохранение добавочных или полных версий текста в локальном хранилище, чтобы можно было вернуться к предыдущей версии, если что-то было изменено по ошибке.
  • Дополнительные параметры форматирования, такие как списки и видео на YouTube.

После использования редактора для редактирования этой истории я обнаружил еще несколько проблем:

  • Множественные пробелы или табуляции в блоках кода удалены, поэтому на данный момент код выглядит ужасно.
  • При форматировании страница прокручивается вверх, что очень раздражает.
  • [i] внутри кодовых блоков преобразуется в курсивные BB-коды при сохранении в старом редакторе
  • Иногда редактор создает какие-то странные теги <span> с большим количеством форматирования CSS без видимой причины.

По сравнению с execCommand новая система с Selection и Range намного сложнее и не очень интуитивно понятна. Я еще не полностью понял его возможности и не знаю, как лучше всего его использовать, но, по крайней мере, на данный момент редактор в основном работает. Это один из тех проектов, над которыми вы работаете время от времени, когда проблема начинает вас беспокоить или вам нужна новая функция. Я также не полностью уверен, что все функции Selection и Range правильно поддерживаются всеми браузерами, поэтому со временем может стать проще реализовать такой редактор форматированного текста.

Ресурсы

Здесь — полный исходный код редактора в его текущем состоянии. У него все еще есть некоторые проблемы, но если вы хотите использовать его в качестве отправной точки для своих собственных проектов, не стесняйтесь.

Первоначально опубликовано на https://pingpoli.de.