Принцип и холст
Эта глава описывает принцип работы редактора страниц, его холст и оверлей.
Принцип: намерения, а не правка
Визуальный редактор страниц не хранит своей копии страницы и не меняет её HTML. Страница описана Document. Редактор показывает, что можно выбрать, переместить, вставить или исправить, и превращает каждое действие человека в намерение — событие с понятными данными.
Цикл всегда один:
- человек действует на холсте;
- редактор отправляет событие-намерение;
- приложение применяет его к своей копии Document;
- приложение проверяет результат
SF.Composition.validate()и сохраняет с ожидаемой ревизией; - приложение заново отрисовывает страницу.
Если Document стал неверным или сервер отказал, страница остаётся прежней, а человек видит понятное сообщение.
Редактор собирается из Smart-компонентов: оверлея холста
<sf-composition-overlay> (справочник), перетаскивания <sf-sortable>
(справочник), редактора текста <sf-inline-editor> (справочник) и закреплённых
панелей <sf-drawer docked> (справочник). Их код
загружается только на странице редактора, поэтому обычные страницы сайта не
получают лишний JavaScript, а опубликованный HTML одинаков с открытым и
закрытым редактором.
Редактор нужен, когда страницу собирает человек в системе управления сайтом: переставляет секции, добавляет блоки из библиотеки, правит заголовки и абзацы. Если страница полностью строится из Recipe и настроек без ручной правки, редактор не нужен.
Холст и оверлей
<sf-composition-overlay for="canvas"> рисует поверх отрисованного документа
рамки наведения и выбора с подписью узла, панель действий и точки вставки
между соседями. Узлы на холсте он находит по data-sf-composition-id. Сам
холст не меняется.
Приложение сообщает оверлею документ, подписи узлов и список действий, а
оверлей сообщает о выборе, действии, вставке и переносе событиями
sf-composition-overlay-select, -action, -insert и -move.
У оверлея есть дерево структуры для работы с клавиатуры: стрелки ходят по узлам, Enter переходит к действиям, Escape снимает выбор. Оверлей ставят перед холстом, чтобы дерево шло первым в порядке Tab.
Пример
В примере библиотека блоков — список sf-sortable в режиме копирования, над
холстом стоит sf-composition-overlay, а двойной щелчок запускает
inline-редактор. Любое изменение сначала проходит SF.Composition.validate();
если Document становится неверным, страница остаётся прежней.
<p id="editor-status" role="status">Загружаем редактор…</p>
<div class="flex flex-col gap-3">
<sf-sortable id="editor-library" class="flex flex-wrap gap-2 p-2 radius-2 bg-surface-container" group="blocks" mode="copy" orientation="horizontal" label="Библиотека блоков">
<div class="flex gap-1 p-1 radius-1 bg-surface" data-sf-sortable-item="content.heading"><span data-sf-sortable-handle aria-hidden="true">⠿</span>Заголовок</div>
<div class="flex gap-1 p-1 radius-1 bg-surface" data-sf-sortable-item="content.paragraph"><span data-sf-sortable-handle aria-hidden="true">⠿</span>Абзац</div>
</sf-sortable>
<sf-composition-overlay id="editor-overlay" for="editor-canvas" accepts="blocks" label="Структура страницы"></sf-composition-overlay>
<div id="editor-canvas" class="p-3 m-top-2 radius-3 bg-surface-container"></div>
<sf-inline-editor></sf-inline-editor>
<details><summary>Текущий Document</summary><pre id="editor-document"></pre></details>
</div>
<style>#editor-canvas [data-sf-composition-id] + [data-sf-composition-id] { margin-block-start: var(--sf-space-3, .75rem); } #editor-canvas h2, #editor-canvas p { margin-block: 0; }</style>
// A minimal page editor: the host owns the Document, Framework components only
// report intent. Every change is validated before the canvas is redrawn.
const status = document.querySelector('#editor-status');
const canvas = document.querySelector('#editor-canvas');
const overlay = document.querySelector('#editor-overlay');
const output = document.querySelector('#editor-document');
const text = (value) => [{ type: 'text', value }];
let created = 0;
let model = {
schema: 'simai.composition.document',
schema_version: 1,
id: 'editor-demo',
profile: 'ui-layout',
root: { id: 'page', type: 'layout.section', slots: { default: [
{ id: 'title', type: 'content.heading', data: { level: 2, content: text('Весенняя распродажа') } },
{ id: 'intro', type: 'content.paragraph', data: { content: text('Дважды щёлкните по тексту, чтобы изменить его.') } },
{ id: 'terms', type: 'content.paragraph', data: { content: text('Перетащите блок за подпись рамки или выберите его в дереве и нажмите Пробел.') } },
] } },
};
const names = { 'layout.section': 'Секция', 'content.heading': 'Заголовок', 'content.paragraph': 'Абзац' };
const find = (node, id) => (node.id === id ? node : Object.values(node.slots || {}).flat().map((child) => find(child, id)).find(Boolean));
const detach = (node, target) => {
for (const children of Object.values(node.slots || {})) {
const index = children.indexOf(target);
if (index >= 0) children.splice(index, 1);
children.forEach((child) => detach(child, target));
}
};
const newNode = (type) => {
created += 1;
return type === 'content.heading'
? { id: `heading-${created}`, type, data: { level: 2, content: text('Новый заголовок') } }
: { id: `paragraph-${created}`, type: 'content.paragraph', data: { content: text('Новый абзац') } };
};
async function draw() {
const result = await SF.Composition.render(model);
canvas.innerHTML = result.html;
overlay.setDocument(model, { describe: (node) => ({ label: `${names[node.type] || node.type} · ${node.id}` }) });
output.textContent = JSON.stringify(model, null, 2);
}
// The host applies a change to a copy and keeps it only if the Document stays valid.
async function change(message, apply) {
const next = structuredClone(model);
apply(next);
if (!SF.Composition.validate(next).valid) {
status.textContent = 'Изменение отклонено: Document не прошёл проверку.';
return;
}
model = next;
await draw();
status.textContent = message;
}
overlay.addEventListener('sf-composition-overlay-insert', (event) => {
const { parent_id: parent, slot, index, item } = event.detail;
change('Блок добавлен.', (next) => find(next.root, parent).slots[slot].splice(index, 0, newNode(item || 'content.paragraph')));
});
overlay.addEventListener('sf-composition-overlay-move', (event) => {
const { node_id: id, parent_id: parent, slot, index } = event.detail;
change('Блок перемещён.', (next) => {
const node = find(next.root, id);
detach(next.root, node);
find(next.root, parent).slots[slot].splice(index, 0, node);
});
});
overlay.addEventListener('sf-composition-overlay-action', (event) => {
const { node_id: id } = event.detail;
if (id === model.root.id) {
status.textContent = 'Корневую секцию удалить нельзя.';
return;
}
change('Блок удалён.', (next) => detach(next.root, find(next.root, id)));
});
canvas.addEventListener('click', (event) => { if (event.target.closest('a')) event.preventDefault(); });
canvas.addEventListener('dblclick', (event) => {
const element = event.target.closest('[data-sf-composition-id]');
const node = element && find(model.root, element.getAttribute('data-sf-composition-id'));
if (!node?.data?.content || element.hasAttribute('data-sf-inline-editing')) return;
event.preventDefault();
SF.Composition.startInlineEdit(element, { content: node.data.content, nodeId: node.id, label: names[node.type] });
});
canvas.addEventListener('sf-inline-edit-commit', (event) => {
const { node_id: id, content } = event.detail;
change('Текст сохранён.', (next) => { find(next.root, id).data.content = content; });
});
(async () => {
// The editor surfaces are Smart components: the loader fetches them because
// their tags are on this page.
await Promise.all(['sf-sortable', 'sf-composition-overlay', 'sf-inline-editor'].map((tag) => customElements.whenDefined(tag)));
overlay.attach(canvas);
overlay.setActions([{ id: 'remove', label: 'Удалить' }]);
overlay.setDropFilter((place) => place.parent_id === 'page');
await draw();
status.textContent = 'Редактор готов. Выберите блок, перетащите блок из библиотеки или дважды щёлкните по тексту.';
})().catch((error) => { status.textContent = `Редактор не загрузился: ${error.message}`; });