К содержанию

Навигация

Принцип и холст

Эта глава описывает принцип работы редактора страниц, его холст и оверлей.

Принцип: намерения, а не правка

Визуальный редактор страниц не хранит своей копии страницы и не меняет её HTML. Страница описана Document. Редактор показывает, что можно выбрать, переместить, вставить или исправить, и превращает каждое действие человека в намерение — событие с понятными данными.

Цикл всегда один:

  1. человек действует на холсте;
  2. редактор отправляет событие-намерение;
  3. приложение применяет его к своей копии Document;
  4. приложение проверяет результат SF.Composition.validate() и сохраняет с ожидаемой ревизией;
  5. приложение заново отрисовывает страницу.

Если 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 становится неверным, страница остаётся прежней.

HTML
<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>
JavaScript
// 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}`; });

Поиск по документации

Введите минимум 2 символа

    Настройки

    Тема
    Размер шрифта
    Ширина страницы