Конструктор кампаний, в котором 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% пользователей и невидимой для всех остальных, а потом раскатываться. Живость — это всегда «живая для кого», и модель состояния, построенная вокруг пер-аудиторного флага с самого начала, удержала нас от того, чтобы прикручивать таргетинг к глобальному вкл/выкл потом.

Откат — то, что делает всё остальное надёжным. Опубликованный контент — это неизменяемый версионированный снимок, а «живая» — указатель на версию, поэтому публикация закрепляет новую версию, а откат просто перенаводит указатель на предыдущую: мгновенно, только контент, без редеплоя. Мы оперлись на это в первую же неделю: кампания ушла со сломанной ссылкой на оффер, и вместо хотфикс-релиза это был возврат указателя на предыдущую версию, исправленная ссылка и повторная публикация. Версия, что была живой час назад, всё ещё лежала на месте, целиком.

Что я проверяю, прежде чем назвать конструктор «готовым»

Готовым я это не назову. Перемещения между контейнерами с клавиатуры до сих пор требуют больше нажатий, чем стоило бы, а в глубоко вложенных рядах остался случай с раскладкой, который я обошёл, а не решил. Но то, ради чего всё затевалось, случилось: двухдневная задача стала днём, который маркетолог в основном тратит на текст, паника со сломанной ссылкой стала откатом, а очередь задач на кампании, направленная в разработку, иссякла. Этот размен я бы сделал снова.