604 lines
23 KiB
JavaScript
604 lines
23 KiB
JavaScript
const state = {
|
||
page: null,
|
||
selected: null,
|
||
clipboard: null,
|
||
};
|
||
|
||
const el = {
|
||
regions: document.querySelector("#regions"),
|
||
reload: document.querySelector("#reload"),
|
||
save: document.querySelector("#save"),
|
||
render: document.querySelector("#render"),
|
||
status: document.querySelector("#status"),
|
||
empty: document.querySelector("#empty-state"),
|
||
form: document.querySelector("#block-form"),
|
||
selectedRegion: document.querySelector("#selected-region"),
|
||
selectedTitle: document.querySelector("#selected-title"),
|
||
id: document.querySelector("#block-id"),
|
||
label: document.querySelector("#block-label"),
|
||
template: document.querySelector("#block-template"),
|
||
anchor: document.querySelector("#block-anchor"),
|
||
enabled: document.querySelector("#block-enabled"),
|
||
contentFields: document.querySelector("#content-fields"),
|
||
json: document.querySelector("#block-json"),
|
||
moveUp: document.querySelector("#move-up"),
|
||
moveDown: document.querySelector("#move-down"),
|
||
duplicate: document.querySelector("#duplicate"),
|
||
copy: document.querySelector("#copy"),
|
||
pasteAfter: document.querySelector("#paste-after"),
|
||
deleteBlock: document.querySelector("#delete-block"),
|
||
};
|
||
|
||
const STATIC_FALLBACK = {
|
||
id: "static-elements",
|
||
type: "staticElements",
|
||
adminLabel: "Статичные элементы",
|
||
enabled: true,
|
||
content: {
|
||
navigation: {
|
||
logoAlt: "главная",
|
||
brandLabel: "NODE.DC",
|
||
links: [
|
||
{ label: "О продукте", href: "index.html#features", target: "" },
|
||
{ label: "Лист ожидания", href: "index.html#hero", target: "" },
|
||
{ label: "Документация", href: "https://docs.ssscript.app/", target: "_blank" },
|
||
{ label: "Тарифы", href: "index.html#pricing-wrap", target: "" },
|
||
],
|
||
},
|
||
notifications: [
|
||
{
|
||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||
title: "Открытая бета",
|
||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||
},
|
||
{
|
||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
||
title: "Альфа для Founder-доступа",
|
||
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||
},
|
||
{
|
||
eyebrowHtml: "СЕЙЧАС",
|
||
title: "Инвайты в закрытую бету уже отправлены.",
|
||
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
||
},
|
||
],
|
||
dock: {
|
||
appTarget: "app-data",
|
||
trashAlt: "Белая иконка корзины.",
|
||
demoFileLabel: "демо-видео.txt",
|
||
},
|
||
assets: {
|
||
webglModelSrc: "./assets/custom/icon.glb",
|
||
logoPng: "./assets/custom/logo.png",
|
||
logoPngAlt: "Логотип NODE.DC",
|
||
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||
appIconAlt: "Иконка NODE.DC",
|
||
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
||
appWordmarkAlt: "Логотип NODE.DC",
|
||
},
|
||
},
|
||
editableFields: [
|
||
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка"),
|
||
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка"),
|
||
field("content.navigation.links.0.label", "Ссылка 1: текст", "text", "text", "Шапка"),
|
||
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr", "Шапка"),
|
||
field("content.navigation.links.1.label", "Ссылка 2: текст", "text", "text", "Шапка"),
|
||
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr", "Шапка"),
|
||
field("content.navigation.links.2.label", "Ссылка 3: текст", "text", "text", "Шапка"),
|
||
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr", "Шапка"),
|
||
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr", "Шапка"),
|
||
field("content.navigation.links.3.label", "Ссылка 4: текст", "text", "text", "Шапка"),
|
||
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr", "Шапка"),
|
||
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html", "html", "Уведомления"),
|
||
field("content.notifications.0.title", "Уведомление 1: заголовок", "text", "text", "Уведомления"),
|
||
field("content.notifications.0.body", "Уведомление 1: текст", "textarea", "text", "Уведомления"),
|
||
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html", "html", "Уведомления"),
|
||
field("content.notifications.1.title", "Уведомление 2: заголовок", "text", "text", "Уведомления"),
|
||
field("content.notifications.1.body", "Уведомление 2: текст", "textarea", "text", "Уведомления"),
|
||
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html", "html", "Уведомления"),
|
||
field("content.notifications.2.title", "Уведомление 3: заголовок", "text", "text", "Уведомления"),
|
||
field("content.notifications.2.body", "Уведомление 3: текст", "textarea", "text", "Уведомления"),
|
||
field("content.dock.appTarget", "Dock: ID приложения", "text", "text", "Dock"),
|
||
field("content.dock.trashAlt", "Dock: alt корзины", "text", "text", "Dock"),
|
||
field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"),
|
||
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.logoPng", "PNG-логотип", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.logoPngAlt", "PNG-логотип: alt", "text", "text", "Модель и логотипы"),
|
||
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.appIconAlt", "Иконка приложения: alt", "text", "text", "Модель и логотипы"),
|
||
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr", "Модель и логотипы"),
|
||
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt", "text", "text", "Модель и логотипы"),
|
||
],
|
||
};
|
||
|
||
const GROUP_COPY = {
|
||
Шапка: "Навигация верхней mac-панели: бренд, ссылки и целевые адреса.",
|
||
Уведомления: "Карточки уведомлений справа сверху. Порядок соответствует текущему stack в shell.",
|
||
Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.",
|
||
"Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.",
|
||
Заголовок: "Главный заголовок и вводный текст выбранной секции.",
|
||
"Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.",
|
||
"Карточка раннего доступа": "Текст внутри центрального окна приложения.",
|
||
Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.",
|
||
"Юридический блок": "Политики, права и нижний текст формы.",
|
||
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
|
||
Пункты: "Текстовые пункты внутри секции.",
|
||
Контент: "Остальные typed-поля выбранного блока.",
|
||
};
|
||
|
||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) {
|
||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path) };
|
||
}
|
||
|
||
function setStatus(message) {
|
||
el.status.textContent = message;
|
||
}
|
||
|
||
function clone(value) {
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function getByPath(source, path) {
|
||
return path.split(".").reduce((value, key) => value?.[key], source);
|
||
}
|
||
|
||
function setByPath(source, path, nextValue) {
|
||
const keys = path.split(".");
|
||
const lastKey = keys.pop();
|
||
let target = source;
|
||
|
||
keys.forEach((key, index) => {
|
||
const nextKey = keys[index + 1] ?? lastKey;
|
||
|
||
if (target[key] == null || typeof target[key] !== "object") {
|
||
target[key] = /^\d+$/.test(nextKey) ? [] : {};
|
||
}
|
||
|
||
target = target[key];
|
||
});
|
||
|
||
target[lastKey] = nextValue;
|
||
}
|
||
|
||
function inferFieldGroup(path) {
|
||
if (path.startsWith("content.navigation.")) return "Шапка";
|
||
if (path.startsWith("content.notifications.")) return "Уведомления";
|
||
if (path.startsWith("content.dock.")) return "Dock";
|
||
if (path.startsWith("content.assets.")) return "Модель и логотипы";
|
||
if (path.startsWith("content.cards.")) return "Тезисы первого экрана";
|
||
if (path.startsWith("content.app.")) return "Карточка раннего доступа";
|
||
if (path.startsWith("content.form.")) return "Форма";
|
||
if (path === "content.legalHtml") return "Юридический блок";
|
||
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
|
||
if (path.startsWith("content.features.")) return "Пункты";
|
||
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
|
||
return "Контент";
|
||
}
|
||
|
||
function ensureStaticElements() {
|
||
if (!state.page.staticElements) {
|
||
state.page.staticElements = clone(STATIC_FALLBACK);
|
||
return;
|
||
}
|
||
|
||
state.page.staticElements.id ||= STATIC_FALLBACK.id;
|
||
state.page.staticElements.type ||= STATIC_FALLBACK.type;
|
||
state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel;
|
||
state.page.staticElements.enabled = true;
|
||
state.page.staticElements.content ||= clone(STATIC_FALLBACK.content);
|
||
state.page.staticElements.editableFields ||= clone(STATIC_FALLBACK.editableFields);
|
||
}
|
||
|
||
function isStaticSelection() {
|
||
return state.selected?.kind === "static";
|
||
}
|
||
|
||
function activeRegion() {
|
||
if (!state.selected) return null;
|
||
|
||
if (isStaticSelection()) {
|
||
return {
|
||
id: "static",
|
||
label: "Статичные элементы",
|
||
description: "Shell-слой страницы: шапка, уведомления, dock и базовые ассеты.",
|
||
};
|
||
}
|
||
|
||
return state.page.regions[state.selected.regionIndex];
|
||
}
|
||
|
||
function activeBlock() {
|
||
if (!state.selected) return null;
|
||
|
||
if (isStaticSelection()) {
|
||
return state.page.staticElements;
|
||
}
|
||
|
||
const region = activeRegion();
|
||
if (!region || state.selected.blockIndex == null) return null;
|
||
return region.blocks[state.selected.blockIndex];
|
||
}
|
||
|
||
function uniqueId(base) {
|
||
const ids = new Set([
|
||
state.page.staticElements?.id,
|
||
...state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)),
|
||
]);
|
||
let candidate = `${base}-copy`;
|
||
let index = 2;
|
||
|
||
while (ids.has(candidate)) {
|
||
candidate = `${base}-copy-${index}`;
|
||
index += 1;
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
|
||
function labelForRegion(region) {
|
||
if (region.id === "beforeCViz") return "Главная";
|
||
if (region.id === "cViz") return "Секции";
|
||
return region.label || region.id;
|
||
}
|
||
|
||
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
||
const button = document.createElement("button");
|
||
const name = document.createElement("span");
|
||
const description = document.createElement("span");
|
||
|
||
button.type = "button";
|
||
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`;
|
||
name.className = "block-name";
|
||
name.textContent = block.adminLabel || block.id;
|
||
description.className = "block-meta";
|
||
description.textContent = meta;
|
||
button.append(name, description);
|
||
button.addEventListener("click", onClick);
|
||
list.append(button);
|
||
}
|
||
|
||
function renderRegions() {
|
||
el.regions.innerHTML = "";
|
||
|
||
const staticNode = document.createElement("section");
|
||
const staticTitle = document.createElement("h3");
|
||
const staticList = document.createElement("div");
|
||
|
||
staticTitle.className = "region-title";
|
||
staticTitle.textContent = "Static";
|
||
staticList.className = "block-list";
|
||
renderBlockButton({
|
||
list: staticList,
|
||
block: state.page.staticElements,
|
||
meta: "shell · nav · notifications · dock",
|
||
active: isStaticSelection(),
|
||
disabled: false,
|
||
onClick: () => {
|
||
state.selected = { kind: "static" };
|
||
renderAll();
|
||
},
|
||
});
|
||
staticNode.append(staticTitle, staticList);
|
||
el.regions.append(staticNode);
|
||
|
||
state.page.regions.forEach((region, regionIndex) => {
|
||
const regionNode = document.createElement("section");
|
||
const title = document.createElement("h3");
|
||
const list = document.createElement("div");
|
||
|
||
title.className = "region-title";
|
||
title.textContent = labelForRegion(region);
|
||
list.className = "block-list";
|
||
|
||
region.blocks.forEach((block, blockIndex) => {
|
||
const isActive =
|
||
state.selected?.kind === "block" &&
|
||
state.selected?.regionIndex === regionIndex &&
|
||
state.selected?.blockIndex === blockIndex;
|
||
|
||
renderBlockButton({
|
||
list,
|
||
block,
|
||
meta: `${block.id} · ${block.template || block.type}`,
|
||
active: isActive,
|
||
disabled: block.enabled === false,
|
||
onClick: () => {
|
||
state.selected = { kind: "block", regionIndex, blockIndex };
|
||
renderAll();
|
||
},
|
||
});
|
||
});
|
||
|
||
regionNode.append(title, list);
|
||
el.regions.append(regionNode);
|
||
});
|
||
}
|
||
|
||
function setStructuralControlsDisabled(disabled) {
|
||
for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) {
|
||
control.disabled = disabled;
|
||
}
|
||
}
|
||
|
||
function renderEditor() {
|
||
const region = activeRegion();
|
||
const block = activeBlock();
|
||
const isStatic = isStaticSelection();
|
||
|
||
if (!block) {
|
||
el.empty.classList.remove("hidden");
|
||
el.form.classList.add("hidden");
|
||
el.selectedRegion.textContent = "NODE.DC / Выберите блок";
|
||
el.selectedTitle.textContent = "Контентный слой";
|
||
return;
|
||
}
|
||
|
||
el.empty.classList.add("hidden");
|
||
el.form.classList.remove("hidden");
|
||
el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`;
|
||
el.selectedTitle.textContent = block.adminLabel || block.id;
|
||
el.id.value = block.id || "";
|
||
el.label.value = block.adminLabel || "";
|
||
el.template.value = block.template || block.type || "";
|
||
el.anchor.value = block.anchor || "";
|
||
el.enabled.checked = block.enabled !== false;
|
||
el.id.readOnly = isStatic;
|
||
el.anchor.disabled = isStatic;
|
||
el.enabled.disabled = isStatic;
|
||
setStructuralControlsDisabled(isStatic);
|
||
renderContentFields(block);
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
}
|
||
|
||
function renderAll() {
|
||
renderRegions();
|
||
renderEditor();
|
||
}
|
||
|
||
function syncBlockFromFields() {
|
||
const block = activeBlock();
|
||
if (!block) return;
|
||
|
||
syncContentFields();
|
||
block.adminLabel = el.label.value.trim() || block.adminLabel || block.id;
|
||
|
||
if (!isStaticSelection()) {
|
||
block.id = el.id.value.trim();
|
||
block.anchor = el.anchor.value.trim() || null;
|
||
block.enabled = el.enabled.checked;
|
||
}
|
||
|
||
el.json.value = JSON.stringify(block, null, 2);
|
||
renderRegions();
|
||
}
|
||
|
||
function groupEditableFields(block) {
|
||
const groups = new Map();
|
||
|
||
for (const editableField of block.editableFields ?? []) {
|
||
const groupName = editableField.group || inferFieldGroup(editableField.path);
|
||
if (!groups.has(groupName)) groups.set(groupName, []);
|
||
groups.get(groupName).push(editableField);
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
function renderContentFields(block) {
|
||
el.contentFields.innerHTML = "";
|
||
|
||
if (!block.editableFields?.length) {
|
||
const note = document.createElement("div");
|
||
note.className = "empty-note";
|
||
note.textContent = "Для этого блока typed-поля ещё не выделены. Пока доступно редактирование через JSON.";
|
||
el.contentFields.append(note);
|
||
return;
|
||
}
|
||
|
||
for (const [groupName, fields] of groupEditableFields(block)) {
|
||
const group = document.createElement("section");
|
||
const head = document.createElement("div");
|
||
const titleWrap = document.createElement("div");
|
||
const title = document.createElement("h3");
|
||
const desc = document.createElement("p");
|
||
const grid = document.createElement("div");
|
||
|
||
group.className = "content-group";
|
||
head.className = "group-head";
|
||
title.textContent = groupName;
|
||
desc.className = "group-desc";
|
||
desc.textContent = GROUP_COPY[groupName] || GROUP_COPY["Контент"];
|
||
titleWrap.append(title, desc);
|
||
head.append(titleWrap);
|
||
grid.className = "group-grid";
|
||
|
||
for (const editableField of fields) {
|
||
const label = document.createElement("label");
|
||
const labelRow = document.createElement("span");
|
||
const labelText = document.createElement("span");
|
||
const kind = document.createElement("span");
|
||
const path = document.createElement("span");
|
||
const value = getByPath(block, editableField.path) ?? "";
|
||
const isLong = editableField.kind === "html" || editableField.kind === "textarea";
|
||
const input = isLong ? document.createElement("textarea") : document.createElement("input");
|
||
|
||
label.className = `field-control${isLong ? " wide" : ""}`;
|
||
labelRow.className = "field-label-row";
|
||
labelText.className = "field-label-text";
|
||
labelText.textContent = editableField.label || editableField.path;
|
||
kind.className = "field-kind";
|
||
kind.textContent = editableField.kind || "text";
|
||
path.className = "field-path";
|
||
path.textContent = editableField.path;
|
||
labelRow.append(labelText, kind);
|
||
|
||
input.dataset.path = editableField.path;
|
||
input.value = value;
|
||
|
||
if (!isLong) {
|
||
input.type = "text";
|
||
input.autocomplete = "off";
|
||
}
|
||
|
||
input.addEventListener("input", () => {
|
||
const active = activeBlock();
|
||
if (!active) return;
|
||
setByPath(active, editableField.path, input.value);
|
||
el.json.value = JSON.stringify(active, null, 2);
|
||
});
|
||
|
||
label.append(labelRow, input, path);
|
||
grid.append(label);
|
||
}
|
||
|
||
group.append(head, grid);
|
||
el.contentFields.append(group);
|
||
}
|
||
}
|
||
|
||
function syncContentFields() {
|
||
const block = activeBlock();
|
||
if (!block) return;
|
||
|
||
for (const input of el.contentFields.querySelectorAll("[data-path]")) {
|
||
setByPath(block, input.dataset.path, input.value);
|
||
}
|
||
}
|
||
|
||
function syncBlockFromJson() {
|
||
if (!state.selected) return false;
|
||
|
||
try {
|
||
const parsed = JSON.parse(el.json.value);
|
||
if (isStaticSelection()) {
|
||
state.page.staticElements = parsed;
|
||
} else {
|
||
const region = activeRegion();
|
||
if (!region) return false;
|
||
region.blocks[state.selected.blockIndex] = parsed;
|
||
}
|
||
renderAll();
|
||
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
|
||
return true;
|
||
} catch (error) {
|
||
setStatus(`Ошибка JSON: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function loadPage() {
|
||
const response = await fetch("/api/page/home");
|
||
if (!response.ok) throw new Error(await response.text());
|
||
state.page = await response.json();
|
||
ensureStaticElements();
|
||
state.selected = { kind: "static" };
|
||
renderAll();
|
||
setStatus("Модель home.json загружена.");
|
||
}
|
||
|
||
async function savePage() {
|
||
if (activeBlock()) syncBlockFromFields();
|
||
|
||
const response = await fetch("/api/page/home", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(state.page),
|
||
});
|
||
|
||
if (!response.ok) throw new Error(await response.text());
|
||
setStatus("content/pages/home.json сохранён.");
|
||
}
|
||
|
||
async function renderHome() {
|
||
const response = await fetch("/api/render/home", { method: "POST" });
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok || !payload.ok) {
|
||
throw new Error(payload.error || "Render failed");
|
||
}
|
||
|
||
setStatus(payload.stdout || "index.html собран.");
|
||
}
|
||
|
||
function moveSelected(delta) {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
const index = state.selected?.blockIndex;
|
||
if (!region || index == null) return;
|
||
|
||
const nextIndex = index + delta;
|
||
if (nextIndex < 0 || nextIndex >= region.blocks.length) return;
|
||
|
||
const [block] = region.blocks.splice(index, 1);
|
||
region.blocks.splice(nextIndex, 0, block);
|
||
state.selected.blockIndex = nextIndex;
|
||
renderAll();
|
||
}
|
||
|
||
function duplicateSelected() {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
const block = activeBlock();
|
||
if (!region || !block) return;
|
||
|
||
const duplicate = clone(block);
|
||
duplicate.id = uniqueId(block.id);
|
||
duplicate.adminLabel = `${block.adminLabel || block.id} — копия`;
|
||
duplicate.scopeIds = true;
|
||
region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate);
|
||
state.selected.blockIndex += 1;
|
||
renderAll();
|
||
}
|
||
|
||
function copySelected() {
|
||
if (isStaticSelection()) return;
|
||
const block = activeBlock();
|
||
if (!block) return;
|
||
|
||
state.clipboard = clone(block);
|
||
setStatus(`Скопирован блок: ${block.adminLabel || block.id}`);
|
||
}
|
||
|
||
function pasteAfterSelected() {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
if (!region || !state.clipboard) return;
|
||
|
||
const pasted = clone(state.clipboard);
|
||
pasted.id = uniqueId(pasted.id);
|
||
pasted.adminLabel = `${pasted.adminLabel || pasted.id} — вставка`;
|
||
pasted.scopeIds = true;
|
||
region.blocks.splice(state.selected.blockIndex + 1, 0, pasted);
|
||
state.selected.blockIndex += 1;
|
||
renderAll();
|
||
}
|
||
|
||
function deleteSelected() {
|
||
if (isStaticSelection()) return;
|
||
const region = activeRegion();
|
||
if (!region || state.selected.blockIndex == null) return;
|
||
|
||
region.blocks.splice(state.selected.blockIndex, 1);
|
||
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
|
||
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
|
||
renderAll();
|
||
}
|
||
|
||
for (const input of [el.id, el.label, el.anchor, el.enabled]) {
|
||
input.addEventListener("input", syncBlockFromFields);
|
||
input.addEventListener("change", syncBlockFromFields);
|
||
}
|
||
|
||
el.json.addEventListener("blur", syncBlockFromJson);
|
||
el.reload.addEventListener("click", () => loadPage().catch((error) => setStatus(error.message)));
|
||
el.save.addEventListener("click", () => savePage().catch((error) => setStatus(error.message)));
|
||
el.render.addEventListener("click", () => renderHome().catch((error) => setStatus(error.message)));
|
||
el.moveUp.addEventListener("click", () => moveSelected(-1));
|
||
el.moveDown.addEventListener("click", () => moveSelected(1));
|
||
el.duplicate.addEventListener("click", duplicateSelected);
|
||
el.copy.addEventListener("click", copySelected);
|
||
el.pasteAfter.addEventListener("click", pasteAfterSelected);
|
||
el.deleteBlock.addEventListener("click", deleteSelected);
|
||
|
||
loadPage().catch((error) => setStatus(error.message));
|