Мы живём в эпоху когда можно написать в чат «сделай мне CRUD» и получить рабочий код через десять секунд что в принципе удобно. И это, если честно, главная причина почему я периодически намеренно лезу в что-то сложное руками — чтобы не разучиться думать о том что происходит внутри.
ИИ я использую. Но в этом проекте он был исключительно быстрой документацией — особенно когда добрался до selection/range API, про которые до этого знал чуть меньше чем ничего. Реализация все равно была за мной.
Так вот — ReadGen. Блочный конструктор README-файлов. Месяц, 2-3 часа в день, React и TypeScript и небольшая пачка дополнительных библиотек для разумного облегчения жизни. Важно понимать что это не коммерческий продукт и не претендует на решение чьей-то боли. Просто техническая задача которую я давно хотел разобрать.
Почему
contenteditable — старый добрый дед среди браузерных API. То что он старый — это да, а вот доброта — под вопросом.
Он искренне хочет помочь, сам расставляет <div> и <br>, сам решает что делать с Enter, сам форматирует как считает нужным. Проблема в том что его никто не спрашивал. Когда тебе нужен предсказуемый блочный редактор с контролируемой структурой данных — такая самодеятельность становится основным источником боли.
Готовые текстовые движки эту боль скрывают. Я хотел её почувствовать лично. Понять где именно браузер начинает делать по-своему и что с этим делать. Это и был настоящий вопрос проекта.
Поначалу казалось что задача не супер сложная, но довольно быстро понял что полноценный текстовый редактор это бесконечные edge cases которые не влезут ни в какой месяц. Граница была проведена жёстко: плоская структура блоков, никакой иерархии. Всё остальное — потом, в другой жизни.
Так задача сузилась до честного и реалистичного объёма, блочный конструктор README без вложенности, с предсказуемым ядром и нормальным экспортом.
Три OM
Между реальным DOM и виртуальным DOM React затесался ещё один — MOM, Markdown Object Model. Пафосное название для того что по сути является flat map структурой документа. Но только по сути — на деле это целое ядро, состоящее из парсера, валидатора, гардов, движка, сериализатора. Это по сути модули без внешних зависимостей (кроме dexie.js в подмодуле storage) которые принимают данные, делают свое дело и отдают, впрочем как и принято в среде порядочных разработчиков.
Архитектурно MOM живёт как отдельный изолированный модуль — ведёт себя как shared слой в FSD, но это не набор утилит а самостоятельное ядро. Всё остальное приложение построено на гибриде FSD и layered подхода, но MOM существует по своим правилам.
Один DOM, два хозяина
Вот где настоящий конфликт. Структурой документа, порядком блоков, состоянием UI управляет React. Но внутри редактируемых блоков он полностью отступает. Там нет react рендеринга, только innerHTML и чистый DOM. Синхронизация происходит в эффекте, источник истины — содержимое contenteditable, и именно оттуда формируется MOM.
export function momToHTML( nodes: Array<MOMAllContent>, parentId: string | null,) { return nodes.map((node) => momNodeToHTML(node, parentId)).join("");}function momNodeToHTML(node: MOMAllContent, parentId: string | null) { if (!MOM.Guard.isTextNode(node)) return ""; const attrs = [ `data-id="${node.id}"`, `data-type="text"`, `data-parent-id="${parentId}"`, node.marks?.bold ? `data-bold="true"` : "", node.marks?.italic ? `data-italic="true"` : "", node.marks?.lineThrough ? `data-linethrough="true"` : "", node.marks?.link ? `data-link="true"` : "", node.url && node.marks?.link ? `data-url=${node.url}` : "", ] .filter(Boolean) .join(" "); return `<span ${attrs}>${node.value}</span>`;}
Два хозяина поделили территорию. React взял всё снаружи, DOM взял всё внутри. С одной стороны казалось бы все круто, так и должно быть, но на самом деле это порождает новый пласт проблем.
Отдельная история внутри этой истории — курсор. Позицию нужно постоянно сохранять и восстанавливать через selection/range API, и синхронизировать это с React. Это отдельная боль внутри общей боли.
Деструктивная синхронизация DOM — innerHTML = '' перед каждой манипуляцией выглядит грубо, зато работает. Можно было конечно написать свой reconciliation но учитывая исходные рамки по времени я решил отказаться.
useEffect(() => { // ... html = MOM.Serializer.momToHTML(children, node.id); ref.current.innerHTML = html; // ...} [...])
Генераторы в сериализаторе
Экспорт документа в .md — финальная точка всей архитектуры. MOM → Serializer → файл. Впервые за все время своего осознанного программирования я решил тут применить генераторы в деле.
Каждый тип узла имеет свою функцию-генератор. Первый yield — контент блока, второй yield "" — пустая строка-разделитель. Тут мы избавляемся от аккумулятора который нужно тащить в глубь и код делаем более плоским и понятным. Array.from(gen).join("\n") в конце собирает всё в строку.
export function* serializeParagraphNode(node: MOMParagraph, nodes: MOMMap) { yield gatherChildren(node.children, nodes); yield "";}
export function momToMarkdown(rootOrder: Array<string>, nodes: MOMMap) { const gen = serializer(rootOrder, nodes); const result = Array.from(gen).join("\n"); return result;}
Drag and drop на Pointer Events
Кастомный drag and drop с анимацией как отдельный хук. И тут я познакомился с довольно таки мощными событиями класса pointer о существовании которого я знал но почему то всегда обходился с touch и mouse ивентами.
ref.addEventListener("pointerdown", startDrag);document.addEventListener("pointermove", drag);document.addEventListener("pointerup", endDrag);document.addEventListener("pointercancel", endDrag);
Pointer Events унифицируют всё что раньше требовало отдельных mouse и touch обработчиков. Без pointercancel drag зависает если браузер решает прервать жест сам. setPointerCapture — чтобы события не терялись когда курсор уходит за пределы контейнера.
Компромиссы
MOMRaw — честное признание границы. Все что не попало в текущую версию движка (таблицы, инлайн-картинки, инлайн-код и т.д.) живёт в одном компромиссном блоке. Вставляй сырой Markdown или HTML, смотри превью через внешнюю библиотеку в изолированном Shadow DOM.
То же самое с блоком кода — MOMCode. Для этого я подключил библиотеку uiw, думаю в будущем заменю его на более современное и понятное решение.
Виртуализации нет, README на 10 000 блоков не существует в природе, проблема надуманная ну или просто не успел (выбирайте сами).
Мелочи которые просто есть и работают: менеджер глобальных шорткатов, тултипы для вставки блоков, эмодзи и форматирования текста, миниатюры документов через modern-screenshot, хранение в IndexedDB без бэкенда.
Заключение
Месяц закончился. contenteditable я теперь знаю лично — и он знает меня. Selection/Range из страшного незнакомца превратился в рабочий инструмент. Понял где React уместен, а где лучше отойти и дать DOM делать своё дело. Нашёл наконец задачу где генераторы оказались не паттерном ради паттерна а реально лучшим решением.
ссылка на оригинал статьи https://habr.com/ru/articles/1033422/