Конструктор кампаний, в котором drag-and-drop — самая лёгкая часть
Внутренний конструктор блоков для маркетинговых «Историй» — одно дерево компонентов, которое и редактор, и живое превью, drag-and-drop как мутация дерева, доступная с клавиатуры пересборка порядка и публикация, достаточно безопасная, чтобы отдать её не-инженеру.
Два года запуск маркетинговой кампании в Облаке Mail.ru означал заведённую задачу. Маркетолог писал текст и оффер в доке, дизайнер рисовал блоки, а фронтендер превращал всё это в «Историю» (полноэкранные промо-карточки на главном экране), настраивал таргетинг и отгружал за релизом. В лучшем случае два дня. В худшем — два дня плюс опечатка в цене, и ещё полдня на её правку, потому что правка тоже шла релизом.
К началу 2025-го это надоело, и мы сделали внутренний инструмент: маркетолог собирает «Историю» из блоков, видит то, что увидит пользователь, задаёт, кто и когда её получит, и публикует сам, без разработчика в контуре. Дальше — о том, чего это стоило на самом деле. Большая часть работы оказалась вовсе не в drag-and-drop, который все представляют, услышав «конструктор»: само перетаскивание стало решённой задачей в ту минуту, когда мы взяли библиотеку. Работа была везде вокруг него.
Какая планка у внутреннего инструмента
Планка тут другая, чем у обычной фичи. Фича готова, когда она работает. Это было готово только когда маркетинг перестал заводить задачи, а значит, должно было выдержать человека, который видит инструмент впервые: того, кто перетащит блок туда, куда я не планировал, или опубликует в девять вечера, когда ни одного разработчика рядом нет. Большинство решений я сверял именно с этим худшим случаем, а ошибался там, где втихую оптимизировал под своё удобство вместо их.
Одно дерево — и редактор, и превью
Редактор и превью рендерятся из одного дерева компонентов. Один PromoCard, один PriceBlock, один ButtonBlock, и каждый рисует себя из одних и тех же пропсов — что когда его редактируют на холсте, что когда показывают в панели превью; различается только mode, прочитанный из контекста.
Соблазнительная альтернатива — редактор, отдающий некое промежуточное описание, и отдельный рендерер, который превращает его обратно в UI. С ней вы тащите два пути кода, которые обязаны совпадать, и рано или поздно они расходятся: блок выглядит правильно, пока вы его редактируете, вы публикуете, а у живой карточки другой межстрочный интервал, потому что рендерер на неделю отстал от рефакторинга. Одно дерево — это просто отсутствие того, чему расходиться.
// один компонент на тип блока — одинаковый вывод в обоих режимах. рамка выделения и ручка —
// абсолютно спозиционированные оверлеи, поэтому режим редактирования не сдвигает раскладку.
const Mode = createContext<'edit' | 'preview'>('preview');
function PriceBlock({ block }: { block: PriceBlockData }) {
const mode = useContext(Mode);
const selected = useSelected(block.id);
// .block — position: relative в обоих режимах, поэтому оверлей ниже ничего
// не меняет в раскладке, которая уедет в прод.
return (
<div className="block">
<p className="price">{formatPrice(block.amount, block.currency)}</p>
{mode === 'edit' && selected && <span className="sel-overlay" aria-hidden />}
</div>
);
}
Держится это на двух вещах. Первая: служебный слой редактора (рамка выделения, ручка перетаскивания, «+» между блоками) должен оставаться вне box model. Добавь рамку в 2px на выделение — и каждый блок прыгает на 2px по клику, а превью уже врёт про отступы. Поэтому слой нарисован оверлеем поверх блока, через outline и абсолютно спозиционированные ручки; собственная раскладка блока — ровно та, что уедет в прод, а выделение только добавляет что-то сверху.
Вторая: поскольку это одно дерево React над одним состоянием, превью отслеживает правку по мере набора — при колокации состояния блока и его мемоизации нажатие клавиши перерендеривает этот блок, а не весь документ. Изоляцию я сначала сделал неправильно. Положил превью в iframe, учебниковый способ держать CSS админки подальше от «Истории», и набор текста залагал: каждое нажатие сериализовало документ, отправляло его через границу фрейма и рендерило заново, так что превью отставало от курсора на такт. Я вернул его в то же дерево и изолировал через Shadow DOM, в который поддерево превью рендерится порталом, с таблицей стилей дизайн-системы, добавленной (adopted) в shadow root. (Синтетические события React не всегда проходят через границу shadow при рендере порталом, и обычно это тут и есть засада; не выстрелило, потому что интерактив и служебный слой живут оверлеем в светлом DOM, а в shadow едет только неинтерактивный показ.) Плата — стили в этот корень я прокидываю сам, тогда как iframe отдал бы чистый документ даром.
Как жест превращается в правку дерева
Кампания — это дерево: «История» держит блоки, а часть блоков — контейнеры для других блоков (ряд, кладущий две карточки рядом, группа с общим фоном). Поэтому дроп — это никогда не просто «переложили сюда», а правка дерева: переупорядочить внутри родителя или перепривязать в другой контейнер на конкретный индекс. Курсор — лишь то, чем пользователь указывает на нужную правку.
// кампания — это дерево блоков. дети живут на блоках-контейнерах; всё адресуется
// по id, поэтому перемещение — это «перепривязать + переиндексировать», а не копия поддерева.
type Block =
| { id: string; type: 'text'; props: TextProps }
| { id: string; type: 'price'; props: PriceProps }
| { id: string; type: 'button'; props: ButtonProps }
| { id: string; type: 'row'; props: RowProps; children: string[] }; // контейнер
type Doc = { root: string[]; blocks: Record<string, Block> }; // нормализованное, адресуемое по id
dnd-kit берёт на себя жест: сенсоры указателя и клавиатуры, детекцию коллизий, движущийся оверлей. На вас остаётся вопрос, который важен в момент дропа, — какой родитель и какой индекс. Для плоского списка это одно число. Для дерева это проекция (projection в терминах dnd-kit): вы читаете горизонтальный офсет перетаскивания, чтобы решить глубину (тянешь вправо — вложить под блок сверху, влево — выскочить на уровень выше), зажимаете её вложенностями, что допускает цель, и только потом резолвите родителя и индекс. dnd-kit отгружает это своим примером sortable-tree, и переосмысление его под наши правила блоков и было большей частью настоящей DnD-работы.
// дроп → правка дерева: открепить от старого родителя, вставить в вычисленные (parent, index).
// off-by-one ниже — это и есть вся суть: ошибёшься — и блок приземлится на одну позицию
// мимо обещанного индикатором, на каждой перестановке вниз внутри одного родителя.
function move(doc: Doc, id: string, toParent: string | 'root', index: number): Doc {
const from = parentList(doc, id); // массив, который держит id прямо сейчас
const to =
toParent === 'root'
? doc.root
: (doc.blocks[toParent] as Extract<Block, { type: 'row' }>).children;
const oldIndex = from.indexOf(id);
from.splice(oldIndex, 1); // открепляем (в реальном коде: клон, без мутации)
// тот же массив и двигаем вниз? открепление выше уже сдвинуло цель на одну влево.
const at = from === to && oldIndex < index ? index - 1 : index;
to.splice(at, 0, id); // вставляем ровно туда, куда показывал индикатор
return doc;
}
Этот off-by-one не гипотетический — он уехал в прод. Где-то неделю перетаскивание блока вниз внутри той же группы роняло его на позицию выше, чем показывал индикатор, пока маркетолог не написал мне, что «блок не встаёт туда, куда я его бросаю». Фикс — три строки выше. Урок был грубее: индикатор дропа — это обещание, и проекция, которая его рисует, обязана вычислить тот же родитель и тот же индекс, что использует дроп, иначе доверие к холсту пропадает почти сразу. На эту проекцию я потратил больше времени, чем на само перетаскивание.
Drag-and-drop, который работает без мыши
Drag-and-drop-интерфейс, построенный только на pointer-событиях, непригоден ни для клавиатуры, ни для скринридера, а в VK доступность не опциональна, так что уже одно это делало первую версию неотгружаемой. Переупорядочивание — базовое действие; если оно работает только мышью, заметная часть пользователей не может сделать его вообще.
Клавиатурное переупорядочивание — это отдельное взаимодействие, которое надо построить: сфокусировать ручку перетаскивания блока, пробел — поднять, стрелки — двигать по допустимым позициям, пробел — положить, escape — отменить и вернуть на место. Клавиатурный сенсор dnd-kit даёт механику, но дальше на вас — сделать каждую ручку настоящей фокусируемой кнопкой с подписью («Переставить блок цены»), выразить движение в терминах допустимых позиций дерева, а не сырых пикселей, и вернуть фокус на перемещённый блок после дропа, чтобы пользователя не выбросило в начало документа.
Скринридеру всё это надо проговорить, иначе для него тишина. Поэтому вежливый live-регион озвучивает каждый шаг: «Поднят блок цены, позиция 2 из 5», «Перемещён на позицию 3 из 5», «Положен». dnd-kit гонит это через свой announcements API; работа — написать объявления, описывающие то дерево, которое маркетолог редактирует, а не общие строки вроде «элемент перемещён».
Одно стоит сказать, потому что за этим тянутся первым делом и зря: aria-grabbed и aria-dropeffect выглядят так, будто сделаны ровно для этого, и они депрекированы и фактически не поддерживаются. В реальных скринридерах работает связка «клавиатурное взаимодействие с управлением фокусом плюс live-region-объявлятор». aria-grabbed пройдёт быстрый взгляд на спецификацию и не скажет живому пользователю ничего.
И уважайте prefers-reduced-motion: пружинная анимация перетаскивания приятна большинству и кому-то делает дурно, поэтому под reduced-motion блок прыгает на позицию, а не подъезжает к ней.
Черновик, валидация, публикация, откат
Всё это впустую, если публикация небезопасна, а «безопасно» для не-инженера — это три вещи: он не даст мне опубликовать сломанное, он покажет, что именно сломано, и если живая кампания окажется неправильной, я откачу её сам, не разыскивая разработчика.
Валидация идёт по схеме, по одной на тип блока, и документ не дойдёт до «публикации», пока её не пройдёт: блок цены без суммы, кнопка без ссылки, «История» без блоков — всё это ловится заранее. Важно, где всплывает ошибка: на блоке, рядом с правкой, а не баннером «что-то не так» сверху. Со схемой это дёшево, потому что она знает, какое поле какого блока упало.
// одна схема на блок; документ валидируется до того, как разблокируется «публикация», а
// ошибка несёт id блока, поэтому всплывает на блоке, а не в глобальном баннере.
const PriceSchema = z.object({
amount: z.number().positive(),
currency: z.enum(['RUB', 'USD', 'EUR']),
});
const result = PriceSchema.safeParse(block.props);
if (!result.success) markInvalid(block.id, result.error); // привязать ошибку к блоку
Публикация — не одно вкл/выкл. Кампания проходит черновик → запланирована → живая → в архиве; «запланирована» несёт старт и опциональный конец, а «живая» — это пер-аудиторно, потому что таргетинг идёт через feature flags: одна и та же кампания может быть живой для 5% пользователей и невидимой для всех остальных, а потом раскатываться. Живость — это всегда «живая для кого», и модель состояния, построенная вокруг пер-аудиторного флага с самого начала, удержала нас от того, чтобы прикручивать таргетинг к глобальному вкл/выкл потом.
Откат — то, что делает всё остальное надёжным. Опубликованный контент — это неизменяемый версионированный снимок, а «живая» — указатель на версию, поэтому публикация закрепляет новую версию, а откат просто перенаводит указатель на предыдущую: мгновенно, только контент, без редеплоя. Мы оперлись на это в первую же неделю: кампания ушла со сломанной ссылкой на оффер, и вместо хотфикс-релиза это был возврат указателя на предыдущую версию, исправленная ссылка и повторная публикация. Версия, что была живой час назад, всё ещё лежала на месте, целиком.
Что я проверяю, прежде чем назвать конструктор «готовым»
- маркетолог, ни разу не видевший инструмент, может собрать и выкатить реальную кампанию без разработчика — проверено на живом человеке, а не предположено;
- редактор и превью — одно дерево компонентов, а служебный слой редактора живёт оверлеем, который никогда не сдвигает раскладку блока;
- каждое перетаскивание работает с клавиатуры от начала до конца, фокус возвращается на перемещённый блок, и каждый шаг озвучивается скринридеру (а
aria-grabbedв коде нет); - документ валидируется по схеме на каждый блок до того, как разблокируется публикация, и каждая ошибка привязана к своему блоку;
- «живая» — пер-аудиторно через флаг, а откат — мгновенное перенаведение указателя на предыдущую неизменяемую версию.
Готовым я это не назову. Перемещения между контейнерами с клавиатуры до сих пор требуют больше нажатий, чем стоило бы, а в глубоко вложенных рядах остался случай с раскладкой, который я обошёл, а не решил. Но то, ради чего всё затевалось, случилось: двухдневная задача стала днём, который маркетолог в основном тратит на текст, паника со сломанной ссылкой стала откатом, а очередь задач на кампании, направленная в разработку, иссякла. Этот размен я бы сделал снова.