Пост «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