
Привет, Хабр! Меня зовут Владимир, я старший инженер по разработке. Мы с командой создали визуальный конструктор страниц – корпоративный, безопасный и, главное, удобный. Про этот проект рассказывали мои коллеги в статье раньше, а я решил осветить техническую сторону вопроса. Материала много, поэтому я разбил его на две части. В этой части о том, как мы прошли путь от ручного копирования HTML-кода до собственного визуального конструктора страниц.
Глава 1. Отправная точка: ограничения и первые костыли
Наш корпоративный портал работал на коробочной open-source CMS — headless-решении для управления веб-контентом. Встроенный редактор предоставлял стандартный набор возможностей: заголовки, параграфы, базовое форматирование, картинки. Этого хватало ровно до тех пор, пока контент-менеджеры не начали запрашивать сложные макеты: сетки, вкладки, аккордеоны, слайдеры.

Встроенный редактор не позволял создать ничего сложнее текста с картинками, а ручная вёрстка каждой страницы силами разработчика сделала бы процесс публикации неоправданно долгим и дорогим — контент-менеджеры ждали бы своей очереди, а команда разработки тонула бы в вёрстке вместо продуктовых задач.
Нужно было быстрое решение, и мы сделали его «на коленке»: отдельную страницу-песочницу, где были собраны готовые HTML-блоки с атрибутом contenteditable="true". Автор открывал песочницу, редактировал текст прямо в блоках, добавлял новые или удалял лишние, после чего копировал итоговый HTML и вставлял его напрямую в код страницы через встроенный в CMS редактор HTML-разметки. Выглядело как костыль, но работало — по крайней мере, первое время.


Готовое решение, однако, создавало серьёзные операционные издержки:
Риск повреждения разметки. Контент-менеджеры работали напрямую с HTML-кодом. Одна случайно удалённая скобка или кавычка — и вёрстка разъезжалась. Диагностика и исправление требовали участия разработчиков, отвлекая команду от продуктовых задач.
Высокий порог входа. Работа с HTML-кодом требовала технических навыков за пределами компетенций контент-менеджеров. Это ограничивало круг сотрудников, способных самостоятельно опубликовать страницу.
Неудобная работа с медиафайлами. Вставка изображений и видео была отдельной проблемой. CMS использовала собственную медиагалерею, и внешний инструмент не имел к ней доступа.
Стало очевидно, что нужен подход, который отделит контент от представления. Предпосылки к созданию собственной CMS мы описывали в отдельной статье — здесь сфокусируемся на конструкторе, который стал её частью.
Глава 2. Почему JSON, а не HTML?
Работая над временным решением, мы всё чаще упирались в одно и то же ограничение: HTML-код, вставленный в страницу, жил своей жизнью. Изменить вёрстку блока на всех страницах сразу? Невозможно. Проверить, что автор случайно не сломал структуру? Только глазами.
Ключевой вывод, к которому мы пришли: контент не должен храниться в формате, привязанном к способу отображения. HTML — это представление. Он описывает, как контент выглядит в браузере, но ничего не говорит о его структуре и смысле.
Мы выбрали JSON как единый источник данных о странице. Вот какие преимущества это дало.
Независимость от клиента. JSON не привязан к конкретной технологии отображения. Каждый клиент сам решает, как рендерить полученные данные: сайт преобразует JSON в HTML, мобильное приложение маппит его на нативные компоненты UI Kit. Контент создаётся один раз, а способы его отображения определяются каждым клиентом отдельно.
Валидация на уровне API. JSON имеет строгую структуру, которую можно описать схемой. Некорректный документ отсекается до того, как попадёт в базу. С HTML такой подход невозможен: отсутствие жёсткой схемы означает, что проверить корректность можно только одним способом — попытаться отобразить и надеяться, что ничего не сломалось.
Модификация без миграций. Дизайн блока меняется централизованно — в коде рендеринга на стороне клиента. Сами данные остаются нетронутыми. Если нужно обновить внешний вид всех блоков определённого типа, достаточно изменить один компонент. Раньше для этого пришлось бы вручную править HTML на десятках страниц.
Расширяемость. Добавление нового типа блока — это новая запись в конфигурации и отдельный React-компонент. Все существующие страницы продолжают работать без изменений. Никаких миграций данных, никакой обратной совместимости.
Глава 3. Как устроен JSON страницы
Страница — массив блоков. Каждый блок: id (UUID), name (тип), data (дерево элементов).
Самый простой блок — текст:

Блок с изображением:

Блок с кнопкой:

Конструктор оперирует двенадцатью типами блоков: текст, кнопка, изображение, слайдер, видео, текст с изображением (два варианта компоновки), текст с видео, сетка, вкладки, аккордеон, тизер. Каждый — отдельный React-компонент. Конфигурация в двух структурах: BLOCKS (метаинформация и компоненты) и BLOCKS_DATA (шаблоны для новых блоков). При добавлении берётся шаблон, генерируется UUID, блок попадает в массив.
Глава 4. Contenteditable и кастомный тулбар
Почему не Markdown и не готовый редактор?
Определившись с JSON-моделью для структуры страницы, мы столкнулись со следующим вопросом: как редактировать текст внутри блоков? Текстовый блок — это не просто строка, а форматированный контент: заголовки, списки, ссылки, жирный и курсив, таблицы, цветной текст, бейджи. Нужен был редактор, который, с одной стороны, даёт авторам привычный WYSIWYG-опыт, а с другой — генерирует чистую разметку без лишних тегов и стилей, пригодную для обратной конвертации в JSON.
Markdown мы отвергли практически сразу. Он отлично подходит для простых текстов, но как только речь заходит о сложном форматировании — цветной текст, бейджи, таблицы с объединёнными ячейками, выравнивание, — его возможностей недостаточно. Кроме того, Markdown требует от авторов знания синтаксиса разметки, а наша цель была прямо противоположной: убрать любой технический барьер.
Готовые WYSIWYG-редакторы мы рассматривали подробно. Рынок делится на три категории:
Коммерческие: CKEditor 5, TinyMCE. Платные лицензии для коммерческого использования, сложная интеграция в нашу блочную систему.
Open-source фреймворки: Slate.js, Draft.js, Lexical. Это конструкторы для создания редакторов — модель данных и API есть, но весь интерфейс нужно писать самому.
Блочные редакторы: Editor.js (вдохновил нас JSON-подходом, но заточен под статьи, а не лендинги с макетными блоками), GrapesJS (генерирует HTML/CSS, а не JSON для рендеринга на разных платформах).
Общая проблема готовых редакторов — грязный HTML на выходе. Лишние теги, наследуемые стили, мусорные атрибуты. Для нас это критично: разметка должна быть чистой и предсказуемой, чтобы однозначно конвертироваться в JSON и обратно.
В итоге мы построили редактирование текста на основе нативного атрибута contenteditable с кастомным тулбаром.
The Good, the Bad and the Ugly
Разработчики CKEditor в статье «ContentEditable — The Good, the Bad and the Ugly» описали проблемы, которые мы испытали на себе.
Хорошее: браузер даёт редактирование «из коробки», execCommand покрывает базовое форматирование.
Плохое: непредсказуемая вставка из буфера, разное поведение клавиш в разных браузерах, неконтролируемая вложенность.
Уродливое: нет API для ограничения структуры блока, Selection API крайне сложен, execCommand признан deprecated.
CKEditor решил эти проблемы, создав виртуальный DOM поверх contenteditable. Мы пошли на компромисс: не делали универсальный редактор, а сделали редактор для конкретной блочной структуры с жёсткой фильтрацией ввода.
Как это работает

Зоны ответственности разделены: React управляет блоками (добавление, удаление, перемещение, клонирование, настройки), contenteditable — текстовым содержимым. Это исключает конфликты.

Тулбар появляется при фокусе. Операции: заголовки, начертание, размер, ссылки и кнопки, цитаты, списки (включая список ссылок с иконками), выравнивание, цвет из корпоративной палитры, бейджи, таблицы, сброс форматирования. Каждый инструмент — независимое расширение.

Синхронизация: двусторонняя конвертация JSON → HTML → JSON. Изменения в DOM триггерят обработчик, который получает innerHTML, сериализует в JSON и обновляет состояние. Обновляется только изменённый блок — остальные не затрагиваются.
Фильтрация вставки: контент из буфера обмена преобразуется в plain text, атрибуты очищаются, запрещённые теги (<meta>, <img>, <script>, <input> и др.) удаляются, часть — разворачиваются (содержимое остаётся, тег удаляется).
Работа с таблицами наглядно демонстрирует расширяемость тулбара. Таблицы — хороший пример того, как архитектура кастомного тулбара позволяет добавлять сложные функции, не меняя ядро редактора. Это отдельное расширение, которое работает по тому же принципу: появляется в тулбаре как кнопка, при клике открывает визуальную сетку выбора размера, после вставки таблицы добавляет собственный набор инструментов для работы с ячейками. Добавление и удаление строк и столбцов, объединение ячеек с автоматической установкой colspan и rowspan, вертикальное выравнивание содержимого, выделение первой строки как заголовка, навигация по Tab с автосозданием новой строки в последней ячейке — всё это реализовано внутри одного расширения. Код работает напрямую с DOM: находит нужные элементы через обход дерева, манипулирует ими, после чего синхронизация фиксирует изменения. Основной редактор ничего не знает о таблицах — для него это просто ещё один набор тегов внутри contenteditable-области.


Такой подход — независимые расширения, каждое со своей логикой — оказался ключевым для развития тулбара. Чтобы добавить новую возможность форматирования, не нужно переписывать ядро: достаточно создать расширение с собственным набором кнопок и обработчиков, и оно автоматически встраивается в тулбар. Именно так у нас появились и бейджи, и список ссылок с иконками, и выделение текста цветом — каждое как самостоятельный модуль.
Глава 5. Забота о пользователе: undo/redo и автосохранение
Работа над страницей может занимать от получаса до нескольких часов. За это время происходит многое: автор экспериментирует с расположением блоков, пробует разные формулировки, загружает изображения. Чтобы конструктор вызывал доверие, он должен прощать ошибки и не терять результат работы. Две механики — undo/redo и автосохранение — решают именно эти задачи.
Undo/Redo на снимках состояния
Кнопки «отменить» и «вернуть» — базовая механика любого редактора. Пользователь может случайно удалить блок, испортить форматирование или передумать после нескольких правок.
Стандартный подход — паттерн Command. Каждое действие оборачивается в объект с методами execute и undo, история хранит цепочку таких объектов. Для contenteditable этот подход быстро становится проблемой: пользователь может за одно действие изменить несколько DOM-узлов — выделить текст, захватывающий часть одного абзаца и часть другого, применить форматирование, затем что-то допечатать. Чтобы корректно отменить такое изменение, нужно знать состояние каждого затронутого узла до операции. Отследить это на уровне отдельных DOM-манипуляций сложно и чревато ошибками.
Мы выбрали более надёжный путь — снимки состояния. Вместо записи отдельных действий сохраняем полную копию массива блоков после каждого изменения. Массив истории со снимками, указатель текущей позиции, ограничение глубины (старые снимки удаляются). При изменении создаётся новый снимок, снимки после указателя удаляются, указатель сдвигается вперёд. Undo — сдвиг назад и восстановление, Redo — сдвиг вперёд.
Объём одного снимка — килобайты. JSON-представление страницы компактно: это структура без дублирования стилей и атрибутов. Нам не нужно анализировать, что изменилось в DOM — сам факт срабатывания синхронизации HTML → JSON означает, что состояние изменилось и пора зафиксировать снимок.
Автосохранение
Редактирование может длиться часами. Случайное закрытие вкладки, сбой браузера или разряд батареи на ноутбуке не должны обнулять результат работы.
Автосохранение работает в фоне: после завершения серии изменений текущее состояние сериализуется в JSON и записывается в localStorage вместе с названием страницы и временной меткой. При явном сохранении на сервер черновик удаляется — значит, данные синхронизированы.
При открытии конструктора для новой страницы проверяется localStorage. Если найден черновик — показывается уведомление с временем последнего сохранения, и пользователь может продолжить работу ровно с того места, где остановился. Дополнительный уровень защиты — предупреждение перед уходом со страницы через beforeunload. Если есть несохранённые изменения, браузер покажет стандартный диалог с вопросом, действительно ли пользователь хочет уйти.
Что дальше
В этой части статьи я рассказал, как мы прошли путь от копирования HTML в режиме исходного кода до собственного визуального конструктора: почему выбрали JSON вместо HTML, как спроектировали блочную модель данных, построили редактирование текста на contenteditable с кастомным тулбаром и добавили undo/redo с автосохранением.
Во второй части расскажу о работе с медиафайлами и функциональности, которая позволяет хостить собранные в нашем конструкторе страницы на внешних площадках. А также о том, как конструктор изменил работу команды из 160+ авторов, и какие технические уроки мы извлекли за время разработки.
Продолжение следует...
