Свой тип свойства в админке Битрикса: конструктор вместо разметки руками
Когда контент-менеджеры вставляют вёрстку руками, рано или поздно её ломают. Лечится это не инструкцией, а тем, что разметку им перестают давать: вместо неё — конструктор прямо на форме элемента. Разбираем, как такой конструктор устроен и на чём он ломается.
Задача: убрать вёрстку из рук менеджера
Была статья базы знаний, в которой инструкции различаются по операционным системам и по средам разработки. Менеджеры собирали такие переключалки вручную: копировали в текст статьи блок стилей, скрипт и разметку. Работало до первой второй группы на странице — обработчик переключения искал элементы по всей странице, поэтому вторая группа ломала первую.
Очевидное решение — научить менеджеров писать разметку аккуратнее — не работает никогда. Поэтому разметку у них забрали совсем: структура вкладок хранится отдельным свойством, а в тексте статьи остаётся только короткий токен-плейсхолдер.
// В тексте статьи менеджер пишет только это:
[TABS:install]
// Резолвер при выводе меняет токен на готовую разметку.
// Вместе с обёрткой <p>, которую визуальный редактор добавляет вокруг
// одиночной строки — иначе в вёрстке останется пустой абзац.
$html = preg_replace_callback(
'~(?:<p>\s*)?\[TABS:([A-Za-z0-9_-]+)\]\s*(?:</p>)?~u',
static fn(array $m): string => renderGroup($groups[$m[1]] ?? null),
$html,
);Сознательная жертва: содержимое вкладок больше не попадает в поисковый индекс сайта — оно лежит в свойстве, а не в тексте. Это обсуждается с заказчиком до начала работ, а не после.
Как встроить свой контрол в форму элемента
Соблазн — повесить обработчик на событие формы и дорисовать свою вкладку. Так можно, но есть путь чище: зарегистрировать собственный тип свойства. Тогда ядро само рисует контрол там, где ему положено, а форму мы не трогаем.
// init.php — регистрируем свой тип свойства инфоблока
$eventManager = \Bitrix\Main\EventManager::getInstance();
$eventManager->addEventHandler('iblock', 'OnIBlockPropertyBuildList', [
KbTabsProperty::class, 'getUserTypeDescription',
]);
final class KbTabsProperty
{
public static function getUserTypeDescription(): array
{
return [
'PROPERTY_TYPE' => 'S',
'USER_TYPE' => 'KbTabs',
'DESCRIPTION' => 'Вкладки статьи (конструктор)',
// Множественное свойство рисуется одним контролом целиком,
// а не N копиями одиночного — за это отвечает Multy-метод.
'GetPropertyFieldHtmlMulty' => [self::class, 'getPropertyFieldHtmlMulty'],
'ConvertToDB' => [self::class, 'convertToDB'],
'ConvertFromDB' => [self::class, 'convertFromDB'],
];
}
}Контракт множественного контрола в ядре не документирован, но подсматривается: имена полей должны совпадать с тем, что ждёт форма. Проще всего открыть штатный тип из ядра и повторить.
// Имена полей значений — строго в формате, который ждёт форма элемента.
// Подсмотрено в ядровом CIBlockPropertyElementList::GetPropertyFieldHtmlMulty:
// <base>[n0][VALUE], <base>[n1][VALUE], ...
// Индексы с префиксом n — это новые значения; ядро само разберёт их в массив.
foreach ($groups as $i => $group) {
printf(
'<input type="hidden" name="%s[n%d][VALUE]" value="%s">',
htmlspecialcharsbx($strHTMLControlName['VALUE']),
$i,
htmlspecialcharsbx(json_encode($group, JSON_UNESCAPED_UNICODE)),
);
}
Ловушка первая: молчаливая потеря данных
Самая дорогая ошибка в этой задаче выглядела так: менеджер открывал статью, ничего не менял, нажимал «Применить» — и все вкладки исчезали.
Причина в том, что скрытые поля со значениями заполнялись только при правке в конструкторе. Если правок не было, форма отправляла пустой набор значений, а ядро честно понимало это как «значений больше нет» и очищало свойство.
// Было: сохраняем модель в скрытые поля только при изменении.
// Открыли форму, ничего не тронули, нажали «Применить» — свойство очищено.
onChange(() => save());
// Стало: сразу при инициализации кладём текущее состояние в поля.
// Форма всегда отправляет то, что реально есть, даже если её не трогали.
function init() {
render();
save(); // ← вот эта строка и есть весь фикс
bindEvents();
}Правило общее и не только про Битрикс: если контрол пишет значение в скрытое поле, поле должно быть заполнено сразу, а не по событию. Иначе «ничего не делал» превращается в удаление.
Ловушка вторая: компонент отдаёт пустое значение
Свойство сохраняется, в админке видно, а на странице пусто. Оказалось, что компонент вывода элемента отдаёт множественное свойство с собственным типом пустым массивом — не null, а именно пустым. Из-за этого проверка «если не задано, дотянем сами» не срабатывала: значение формально было.
// Не работает: значение формально есть, но оно пустое
$groups = $arResult['PROPERTIES']['KB_TABS']['VALUE'] ?? null;
// Работает: тянем напрямую и только тогда, когда токен в тексте реально есть.
// Лишний запрос к базе на каждой статье нам не нужен.
$groups = [];
if (str_contains($html, '[TABS:')) {
$res = CIBlockElement::GetProperty(
$arResult['IBLOCK_ID'],
$arResult['ID'],
[],
['CODE' => 'KB_TABS'],
);
while ($row = $res->Fetch()) {
if ($row['VALUE'] !== null && $row['VALUE'] !== '') {
$groups[] = $row['VALUE'];
}
}
}Правило на будущее: множественным свойствам в результате компонента не доверять. Проверять не «есть ли ключ», а есть ли в нём данные.
Ловушка третья: встроенный редактор в модальном окне
Сначала тело вкладки правилось самописным мини-редактором. Заказчик резонно спросил, почему в статье набор стилей один, а во вкладках другой. Пришлось встраивать штатный визуальный редактор — и там своя коллекция граблей.
- Экземпляр редактора третьей версии берётся через свой реестр, а не через свойство DOM-элемента: по привычному пути возвращается редактор второй версии, и попап открывается пустым
- Всплывающие меню редактора имеют встроенный приоритет наложения — модалка обязана быть ниже, иначе она накрывает выпадающий список стилей
- Редактор нельзя переносить по DOM: перенос перезагружает его внутренний фрейм и ломает кнопки. Модалка рисуется сразу вокруг редактора, а «открытие» — это класс на обёртке
- У предков модалки не должно быть трансформаций: трансформация делает предка точкой отсчёта для фиксированного позиционирования, и всплывашки уезжают в сторону
// Экземпляр редактора: только через реестр редактора третьей версии.
// BX('bxed_KB_TABS_LHE').pMainObj вернёт редактор второй версии — попап будет пустым.
const editor = window.BXHtmlEditor.Get('KB_TABS_LHE');
editor.SetContent(html);
// Модалку не двигаем по DOM — только показываем классом.
// И следим за размером: редактор сам не растянется под увеличенное окно.
new ResizeObserver(() => {
editor.SetConfigHeight(box.clientHeight - HEADER_H);
editor.ResizeSceleton();
}).observe(box);Отдельная тонкость: выпадающий список пользовательских стилей показывает стили того сайта, к которому привязан редактор. Для свойства инфоблока это первый сайт инфоблока, а не тот, на котором статья выводится. Не передашь его явно — менеджер увидит пустой список стилей и придёт с вопросом.
// Стили редактора берутся из .styles.php ПЕРВОГО сайта инфоблока.
// Передаём его явно, иначе список стилей окажется пустым.
$site = CIBlock::GetSite($iblockId)->Fetch()['LID'] ?? 's1';
CFileMan::AddHTMLEditorFrame(
'KB_TABS_LHE',
$content,
'KB_TABS_LHE_TYPE',
'html',
['height' => 400],
'N', 0, '', '', $site,
);Что в итоге получилось
- Менеджер не пишет ни строчки разметки: группы, оси и вкладки собираются кнопками
- Справа живое превью — видно ровно то, что увидит читатель
- В текст статьи вставляется один короткий токен, который нельзя сломать опечаткой в вёрстке
- Несколько групп на странице не мешают друг другу: переключение изолировано по группам
- Печать в PDF отдаёт те вкладки, которые открыты на экране — активные передаются в адресе страницы
И главный вывод, ради которого всё затевалось: надёжность здесь дала не проверка ввода, а отсутствие самой возможности ввести что-то не то. Пока у менеджера есть поле, куда можно вставить разметку, он однажды вставит её неправильно.
Нужна такая же форма
Кастомизация админки — обычная для нас работа: свои вкладки, типы свойств, конструкторы. Расскажите, что должны делать ваши контент-менеджеры, и мы предложим, как убрать у них возможность ошибиться.