Пост «They don't make 'em like Sublime Text anymore» откликнулся у многих. Современный софт часто оставляет желать лучшего. Это натолкнуло на мысль: раз уж получается писать посредственный код, почему бы не попробовать написать свой текстовый редактор?

VS Code построен поверх Monaco Editor, а это настоящий ад из вложенных <div>. Долгое время не удавалось пересесть на VS Code — старенький Mac на Intel был слишком медленным для этого. Проблема исчезла с покупкой Mac на Apple Silicon. Если уж это стало отправной точкой, есть куда расти, экспериментируя со своими ошибками.

Canvas

Первый эксперимент — рендеринг всего содержимого на элементе <canvas>.

Со стороны незаметно, но процессор проделывает немалую работу, чтобы отрисовывать эту картинку с частотой 60–120 кадров в секунду. Отсутствие интерактивности — очевидная проблема для текстового редактора.

Был составлен список функций уровня «минимально жизнеспособного продукта», и все они реализованы.

  • Установка текстового курсора по клику указателем
  • Перемещение курсора стрелками
  • Подсветка текущей строки
  • Ввод текста
  • Красивая анимация курсора

Следующее демо уже интерактивно — можно кликать и печатать.

Прежде чем кто-то напишет про биндинги Vim: помолчите, есть проблемы поважнее. Canvas не даёт вообще ничего «бесплатно». Среди недостающих желаемых функций:

  • Выделение текста
  • История отмены/повтора действий
  • Вставка многострочного текста
  • Прокрутка при переполнении

Последний пункт критически важен. Жизнь слишком коротка, чтобы реализовывать собственные эластичные полосы прокрутки. Решено было схитрить и использовать нативный overflow браузера на скрытом элементе. Элемент <div> подгоняется по размеру под текст на canvas, а позиция прокрутки используется для вычисления смещений при отрисовке на canvas.

Результат обнадёживает, но есть и разочарование: <canvas> абсолютно недоступен для вспомогательных технологий. Можно было бы добавить выделение текста и прочие функции, но фундаментальную проблему с доступностью это не решит.

Пришла идея получше.

Content editable

Вместо отрисовки текста на <canvas> можно рендерить его нативно в том же overflow-<div> и сделать редактируемым через атрибут contenteditable. У этого атрибута есть значение plaintext-only, идеально подходящее для кода: весь контент остаётся в одном текстовом узле.

<div
  contenteditable="plaintext-only"
  autocapitalize="off"
  autocorrect="off"
  spellcheck="false"
  translate="no">
  <!-- text goes here -->
</div>

Атрибуты вроде spellcheck обязательно нужно отключать, иначе будут случаться всплески задержки ввода. Сколько дней ушло на то, чтобы это выяснить? Дни!

contenteditable даёт нативное выделение текста, историю отмены и так далее. Браузер бесплатно подключает массу полезной функциональности для доступности.

Selection API предоставляет метрики, которые используются для отрисовки собственного текстового курсора поверх. Доступен и псевдоэлемент ::selection, так что его тоже можно стилизовать. Нативный caret-color сделан невидимым — возможно, так делать не стоило.

Техника с contenteditable выглядит перспективно, но начиная с определённого количества символов проявляются странные проблемы с производительностью. Браузеры на Chromium работают хуже, чем WebKit и то, во что превратился Firefox, но предсказуемости в этом мало.

Textarea

А что если вместо contenteditable в режиме plaintext использовать простой <textarea>? Коротко: да, это жизнеспособно. Оказалось, что <textarea> работает гораздо производительнее на длинных текстах.

В финальную демку добавлена ещё и подсветка синтаксиса.

Изначальный план заключался в использовании кастомных ::highlight на элементе contenteditable. Но <textarea> не поддерживает CSS-хайлайты, поэтому потребовался третий слой. Для демонстрации добавлен небольшой суп из <div> для видимых строк, чтобы применить MicroLighter.

Правка: подсказали, что новый OpaqueRange API открывает доступ к кастомным хайлайтам для <textarea> — здорово!

Правка 2: а EditContext API улучшает работу с вводом на <canvas>.

Слишком много CSS-хайлайтов — ещё одно узкое место по производительности. Более надёжным решением стало бы использование Tree-sitter для построения дерева синтаксиса и обхода только видимых строк для генерации подсветки. Была надежда полностью избежать виртуализированной прокрутки, но её можно улучшить с помощью inverse sticky technique. Либо можно вернуться к contenteditable, поскольку размеры файлов, которые предполагается редактировать, не упираются в стену производительности.

Так или иначе, выглядит неплохо, не правда ли?

Похоже на 90% текстового редактора с 1% его функций. Отсюда довольно легко дорисовать остальную сову. Есть соблазн продолжать, но потом вспоминаются все эти мелочи вроде отступов табуляцией. Сейчас клавиша Tab просто перехватывается и вставляет два пробела…

Демки выше неоптимизированы и далеки от идеальной доступности, но по крайней мере это не изначально проигрышная позиция. Рендеринг на <canvas> был бы настоящим кошмаром.

Проект пока отправляется в стол — до лучших времён.

Строки и текстовые диапазоны в JavaScript работают с кодовыми единицами UTF-16. Наивные ошибки на этой почве возникают очень легко, и наверняка демки выше ими полны. На прощание — код-пример, чтобы было над чем поломать голову.

"🍋‍🟩".length; // 5

[..."🍋‍🟩"].length; // 3

const segmenter = new Intl.Segmenter("en", {granularity: "grapheme"});
[...segmenter.segment("🍋‍🟩")].length; // 1