The share image and featured image of a page become image fields with the library dialog, as do image settings of plugins. The editor offers forms, sections, categories and tags as lists, a page search next to every link target and in text links, the redirect target has a page search, and the form editor picks the reply field, the condition and the option images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1596 lines
101 KiB
JavaScript
1596 lines
101 KiB
JavaScript
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||
import { openPageDialog } from './page-picker.js';
|
||
import { createDesignPanel } from './design-panel.js';
|
||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||
import { createRichText } from './rich-text.js';
|
||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||
import { preview, rowPreview } from './library-previews.js';
|
||
import { decorateContainers } from './preview-containers.js';
|
||
import { initMotion } from './motion.js';
|
||
import { initSliders } from './slider.js';
|
||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js';
|
||
|
||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||
|
||
const root = document.querySelector('#editor');
|
||
const $ = selector => document.querySelector(selector);
|
||
// Pages live under /admin/api/pages/{id}, shared areas under /admin/partials/{id}. Both offer the
|
||
// same document, draft and preview endpoints, so this file does not care which one it drives.
|
||
const api = root.dataset.api;
|
||
const editingArea = 'page' !== root.dataset.kind;
|
||
const adminBase = document.querySelector('meta[name="admin-base"]').content;
|
||
const mayScript = 'true' === root.dataset.canScript;
|
||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
|
||
// The preview is drawn at a fixed width and scaled down to fit. The overlay above it needs the
|
||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||
const collapsed = new Set();
|
||
let renderedProperties = null;
|
||
let revealAfterPreview = null;
|
||
let previewPlayback = false, stopPlayback = () => {};
|
||
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
// Worth a note only when the website actually follows the browser (Einstellungen → Bewegung);
|
||
// the preview carries that decision as a class on its <html>.
|
||
const showMotionStatus = () => {
|
||
const respects = $('#preview').contentDocument?.documentElement.classList.contains('motion-respect') ?? false;
|
||
$('#motion-status').hidden = !(motionPreference.matches && respects);
|
||
};
|
||
motionPreference.addEventListener('change', showMotionStatus);
|
||
showMotionStatus();
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// The sidebar shows one thing at a time.
|
||
//
|
||
// `elements` is the library, `structure` the tree, `page` the page settings and `element` the
|
||
// options of the selected block. The last one is not a tab: it appears when something is
|
||
// selected and gives way to the library when the selection is dropped - the Flatsome rhythm of
|
||
// "pick, edit, back to the list".
|
||
// ---------------------------------------------------------------------------------------------
|
||
let view = 'elements';
|
||
function setView(name) {
|
||
view = name;
|
||
const sidebar = $('.sidebar');
|
||
sidebar.dataset.view = name;
|
||
const showsProperties = name === 'element' || name === 'page';
|
||
for (const section of sidebar.querySelectorAll('.sidebar-view')) section.hidden = section.dataset.view !== (showsProperties ? 'element' : name);
|
||
// The tabs stay in reach while an element is open, so the library and the tree are one click
|
||
// away; none of them is marked then, because the element view is not a tab.
|
||
for (const tab of sidebar.querySelectorAll('.sidebar-nav [role=tab]')) tab.setAttribute('aria-selected', String(tab.dataset.view === name));
|
||
$('.sidebar-element').hidden = name !== 'element';
|
||
$('.property-tabs').hidden = name !== 'element';
|
||
$('.sidebar-foot').hidden = name !== 'element';
|
||
$('.sidebar-body').scrollTop = 0;
|
||
}
|
||
|
||
function playPreview() {
|
||
stopPlayback(); stopPlayback = () => {};
|
||
const button = $('#preview-motion');
|
||
button.setAttribute('aria-pressed', String(previewPlayback));
|
||
button.title = previewPlayback ? 'Bewegung in der Vorschau stoppen' : 'Bewegung in der Vorschau abspielen';
|
||
button.setAttribute('aria-label', button.title);
|
||
const doc = $('#preview').contentDocument;
|
||
if (previewPlayback && doc?.body) {
|
||
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
|
||
stopPlayback = () => { stopMotion(); stopSliders(); };
|
||
}
|
||
}
|
||
|
||
$('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); });
|
||
|
||
function notify(message = '', recover = false) {
|
||
$('#message').textContent = message;
|
||
$('#message').hidden = !message;
|
||
$('#recovery').hidden = !recover;
|
||
}
|
||
|
||
async function request(path, body, signal) {
|
||
let response;
|
||
try {
|
||
response = await fetch(api + path, body === undefined ? { credentials: 'same-origin' } : {
|
||
method: 'POST', credentials: 'same-origin', signal,
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify(body),
|
||
});
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') throw error;
|
||
throw new Error('Die Verbindung ist unterbrochen. Deine Änderungen bleiben im Editor. Bitte erneut versuchen oder als JSON sichern.');
|
||
}
|
||
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Sichere deine Änderungen und melde dich erneut an.');
|
||
if (!response.ok) {
|
||
if (response.status === 409) conflict = true;
|
||
let message = response.status === 403 ? 'Für diese Aktion fehlt die Berechtigung oder die Sitzung ist abgelaufen.' : `Die Anfrage ist fehlgeschlagen (${response.status}). Deine Änderungen bleiben im Editor.`;
|
||
if (response.headers.get('content-type')?.includes('application/json')) message = (await response.json()).error || message;
|
||
throw new Error(message);
|
||
}
|
||
return response.headers.get('content-type')?.includes('application/json') ? response.json() : response.text();
|
||
}
|
||
|
||
function controls() {
|
||
const ready = Boolean(history);
|
||
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
|
||
$('#save').disabled = !ready || busy || conflict || !history.dirty;
|
||
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
|
||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||
$('#redo').disabled = !ready || busy || !history.future.length;
|
||
const current = ready ? locate(history.document.blocks, selected) : null;
|
||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
|
||
if (current) {
|
||
$('#move-up').disabled ||= current.index === 0;
|
||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||
// Ausruecken heisst: neben den Elternblock. Was dort nicht stehen darf (eine Spalte, ein
|
||
// Akkordeoneintrag), bleibt, wo es ist.
|
||
$('#outdent').disabled ||= !current.parent || !dropList(history.document, locate(history.document.blocks, current.parent.id)?.parent?.id ?? null, current.block);
|
||
$('#go-parent').disabled ||= !current.parent;
|
||
}
|
||
updateLibrary();
|
||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||
// Waehrend des Speicherns ist alles gesperrt - und danach nur das wieder frei, was diese
|
||
// Sperre geschlossen hat. Das Design-Bedienfeld schaltet Knoepfe gezielt ab ("Gestaltung
|
||
// einfuegen" ohne Zwischenablage), und die muessen abgeschaltet bleiben.
|
||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => {
|
||
if (busy) { if (!input.disabled) { input.disabled = true; input.dataset.busyLock = '1'; } }
|
||
else if (input.dataset.busyLock) { input.disabled = false; delete input.dataset.busyLock; }
|
||
});
|
||
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||
$('[data-panel="design"]').disabled ||= !current;
|
||
$('#open-media-library').disabled = !ready || busy;
|
||
$('#preview-motion').disabled = !ready || busy;
|
||
}
|
||
|
||
function renderTree() {
|
||
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
|
||
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
|
||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''} ${block.props?.title || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||
const build = blocks => {
|
||
const list = document.createElement('ul'); list.className = 'structure-list';
|
||
for (const block of blocks) {
|
||
if (query && !matches(block)) continue;
|
||
const item = document.createElement('li');
|
||
if (block.children?.length) {
|
||
const toggle = document.createElement('button'); toggle.className = 'tree-toggle'; toggle.type = 'button';
|
||
toggle.textContent = collapsed.has(block.id) && !query ? '▸' : '▾';
|
||
toggle.dataset.toggle = block.id;
|
||
toggle.setAttribute('aria-label', `${blockLabel(block)} ${block.id} ${collapsed.has(block.id) ? 'öffnen' : 'einklappen'}`);
|
||
toggle.setAttribute('aria-expanded', String(!collapsed.has(block.id) || Boolean(query)));
|
||
toggle.disabled = Boolean(query); item.append(toggle);
|
||
}
|
||
const button = document.createElement('button');
|
||
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
|
||
button.dataset.blockId = block.id;
|
||
button.setAttribute('aria-pressed', String(block.id === selected));
|
||
const displayText = block.props?.text || block.props?.alt || block.props?.title || blockLabel(block);
|
||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText}`);
|
||
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block);
|
||
const text = document.createElement('span'); text.textContent = displayText;
|
||
button.append(icon, text); item.append(button);
|
||
if (block.layout?.visibility && block.layout.visibility !== 'all') {
|
||
const badge = document.createElement('small'); badge.className = 'tree-visibility';
|
||
badge.textContent = VISIBILITY[block.layout.visibility];
|
||
button.title = badge.textContent; button.append(badge);
|
||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText} – ${badge.textContent}`);
|
||
}
|
||
if (block.id === selected) {
|
||
// In the tree a click only selects, so the tree stays open while moving through the
|
||
// page. Editing is one step further - this button, or a double click.
|
||
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'tree-edit';
|
||
edit.dataset.editBlock = block.id; edit.textContent = 'Bearbeiten';
|
||
edit.setAttribute('aria-label', `${blockLabel(block)} bearbeiten`);
|
||
item.append(edit);
|
||
}
|
||
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||
list.append(item);
|
||
}
|
||
return list;
|
||
};
|
||
$('#structure').replaceChildren(build(history.document.blocks));
|
||
if (!$('#structure .tree-item')) { const empty = document.createElement('p'); empty.className = 'panel-hint'; empty.textContent = 'Keine passenden Elemente.'; $('#structure').append(empty); }
|
||
if (activeId) Array.from($('#structure').querySelectorAll('button')).find(button => button.dataset.blockId === activeId)?.focus({ preventScroll: true });
|
||
}
|
||
|
||
function choose(id, scroll = true) {
|
||
if (busy) return;
|
||
if (id !== selected && pendingInsert) setPendingInsert(null);
|
||
selected = locate(history.document.blocks, id) ? id : null;
|
||
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
||
if (!selected) propertyPanel = 'content';
|
||
history.mergeKey = null;
|
||
syncView();
|
||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||
}
|
||
|
||
/**
|
||
* Selecting opens the element; dropping the selection goes back to the library. The tree is
|
||
* the exception: there the selection only moves the highlight, editing is one more step.
|
||
*/
|
||
function syncView() {
|
||
if (selected && view !== 'structure') setView('element');
|
||
else if (!selected && view === 'element') setView('elements');
|
||
}
|
||
|
||
function highlight(scroll = false) {
|
||
const doc = $('#preview').contentDocument;
|
||
if (!doc) return;
|
||
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||
const element = selected ? doc.getElementById(selected) : null;
|
||
if (element) {
|
||
element.classList.add('editor-selected');
|
||
// Scroll the preview window only. scrollIntoView would also drag the editor document along
|
||
// with it, and the whole workspace would slide out from under the header.
|
||
const win = doc.defaultView, box = element.getBoundingClientRect();
|
||
if (scroll && win && (box.top < 0 || box.bottom > win.innerHeight)) win.scrollTo({ top: win.scrollY + box.top - 40 });
|
||
}
|
||
overlay?.sync();
|
||
}
|
||
|
||
function renderProperties() {
|
||
const block = locate(history.document.blocks, selected)?.block;
|
||
if (!block) propertyPanel = 'content';
|
||
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
||
if (context !== renderedProperties) $('.sidebar-body').scrollTop = 0;
|
||
renderedProperties = context;
|
||
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
|
||
const panel = $('#properties'); panel.replaceChildren();
|
||
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||
// The way up, as a trail above the name: page, then every container the element sits in.
|
||
$('#breadcrumbs').replaceChildren();
|
||
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)].slice(0, -1)) {
|
||
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
|
||
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||
}
|
||
if (block && isPluginBlock(block) && !pluginDefinition(block)) {
|
||
// The core has no schema for a plugin block, so it offers structure actions only and never
|
||
// touches its stored properties.
|
||
const note = document.createElement('p'); note.className = 'panel-note';
|
||
note.textContent = `Der Baustein „${block.type}“ stammt aus einer Erweiterung, die in dieser Installation nicht verfügbar ist. Sein Inhalt bleibt beim Speichern und Veröffentlichen unverändert erhalten. Verschieben, Duplizieren und Löschen sind möglich, Bearbeiten nicht.`;
|
||
panel.append(note);
|
||
return;
|
||
}
|
||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); renderVisibility(block, panel); return; }
|
||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||
renderContent(block, panel);
|
||
if (block) renderVisibility(block, panel);
|
||
}
|
||
|
||
/** On which devices the element is shown. Part of the content, so it sits at its end. */
|
||
function renderVisibility(block, panel) {
|
||
const group = document.createElement('details'); group.className = 'design-group';
|
||
group.open = (block.layout?.visibility ?? 'all') !== 'all';
|
||
const summary = document.createElement('summary'); summary.textContent = 'Sichtbarkeit';
|
||
const body = document.createElement('div'); body.className = 'design-group-body';
|
||
const label = document.createElement('label'); label.htmlFor = 'block-visibility'; label.textContent = 'Anzeigen';
|
||
const select = document.createElement('select'); select.id = 'block-visibility';
|
||
for (const [value, text] of Object.entries(VISIBILITY)) select.append(new Option(text, value));
|
||
select.value = block.layout?.visibility ?? 'all';
|
||
select.addEventListener('change', () => {
|
||
history.change(doc => setVisibility(doc, block.id, select.value));
|
||
renderTree(); controls(); schedulePreview();
|
||
});
|
||
const help = document.createElement('p'); help.className = 'field-help'; help.id = 'visibility-help';
|
||
help.textContent = 'Desktop ab 1025 px · Tablet 768–1024 px · Mobil bis 767 px. Ausgeblendete Elemente bleiben in der Struktur auswählbar.';
|
||
select.setAttribute('aria-describedby', help.id);
|
||
body.append(label, select, help); group.append(summary, body); panel.append(group);
|
||
}
|
||
|
||
function renderContent(block, panel) {
|
||
let imagePreview;
|
||
const field = (label, key, value, options = {}) => {
|
||
const container = options.container || panel;
|
||
const id = `property-${key}`;
|
||
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||
let input;
|
||
if (options.choices) {
|
||
input = document.createElement('select');
|
||
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
|
||
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||
input.id = id; input.value = value ?? '';
|
||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min ?? '1'; input.max = options.max ?? '8000'; }
|
||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||
if (options.maxLength) input.maxLength = options.maxLength;
|
||
let previousChoice = input.value;
|
||
const applyValue = () => {
|
||
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
|
||
// Native selects may emit input, change, or both. Apply each selection once and keep
|
||
// the control in place while its popup or keyboard navigation is active.
|
||
if (options.choices && input.value === previousChoice) return;
|
||
previousChoice = input.value;
|
||
history.change(doc => {
|
||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||
if (!current) return;
|
||
if (options.anchor) {
|
||
const anchor = String(next).trim().replace(/^#/, '');
|
||
if (anchor) { current.anchor = anchor; upgradeDocument(doc, 21); } else delete current.anchor;
|
||
}
|
||
else if (options.layout) (current.layout ??= {})[key] = next;
|
||
else if (selected) {
|
||
(current.props ??= {})[key] = next;
|
||
// Decorative means "no text": an image loses its alternative text, an icon its label.
|
||
if (key === 'decorative' && next) { current.props.alt = ''; if (current.type === 'icon') current.props.label = ''; }
|
||
}
|
||
else current[key] = next;
|
||
if (key === 'caption') upgradeDocument(doc, 3);
|
||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||
if (key === 'collapse') upgradeDocument(doc, 22);
|
||
if (key === 'faq') upgradeDocument(doc, 23);
|
||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||
options.onInput?.(next);
|
||
if (key === 'decorative') { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); }
|
||
renderTree(); controls(); schedulePreview();
|
||
};
|
||
input.addEventListener('input', applyValue);
|
||
if (options.choices) input.addEventListener('change', applyValue);
|
||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||
if (key === 'href' || options.link) {
|
||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||
// Eine eigene Seite sucht man, statt ihren Pfad zu kennen.
|
||
const choose = value => { input.value = value; applyValue(); history.mergeKey = null; };
|
||
const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen';
|
||
pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||
const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf);
|
||
container.append(row);
|
||
// Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest.
|
||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||
}
|
||
};
|
||
if (!block) {
|
||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||
return;
|
||
}
|
||
const plugin = pluginDefinition(block);
|
||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||
/*
|
||
* Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft.
|
||
* Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu
|
||
* verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld.
|
||
*/
|
||
const pick = (kind, current, empty = null) => {
|
||
const list = designSchema?.choices?.[kind];
|
||
if (!list) return {};
|
||
const choices = {};
|
||
if (empty !== null) choices[''] = empty;
|
||
else if (!current) choices[''] = 'Bitte wählen …';
|
||
Object.assign(choices, list);
|
||
if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`;
|
||
return { choices };
|
||
};
|
||
if (block.type === 'text') {
|
||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||
const rich = createRichText(block.props.text, markup => {
|
||
history.change(doc => {
|
||
upgradeDocument(doc, 12);
|
||
const current = locate(doc.blocks, block.id)?.block;
|
||
if (current) current.props.text = markup;
|
||
}, `rich-${block.id}`);
|
||
controls(); schedulePreview();
|
||
}, { label: 'Text', plain: history.document.schemaVersion < 12 });
|
||
const label = document.createElement('label'); label.textContent = 'Text';
|
||
const help = document.createElement('p'); help.className = 'field-help';
|
||
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
|
||
panel.append(label, rich.element, help);
|
||
}
|
||
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||
if (INLINE_TEXT[block.type]) {
|
||
const inline = document.createElement('button');
|
||
inline.className = 'secondary full-width';
|
||
inline.type = 'button';
|
||
inline.textContent = 'Direkt in der Vorschau bearbeiten';
|
||
inline.addEventListener('click', () => startInlineEdit(block.id));
|
||
const note = document.createElement('p');
|
||
note.className = 'field-help';
|
||
note.textContent = 'Oder in der Vorschau doppelt auf den Text klicken. Escape beendet die Bearbeitung.';
|
||
panel.append(inline, note);
|
||
}
|
||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||
if (block.type === 'iconcard') {
|
||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||
}
|
||
if (block.type === 'stat') {
|
||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||
field('Beschriftung', 'label', block.props.label, { maxLength: 80 });
|
||
}
|
||
if (block.type === 'quote') {
|
||
field('Zitat', 'text', block.props.text, { multiline: true, maxLength: 1000 });
|
||
field('Name', 'author', block.props.author, { maxLength: 120 });
|
||
field('Rolle oder Firma', 'role', block.props.role || '', { maxLength: 120 });
|
||
field('Bewertung', 'rating', block.props.rating, { number: true, choices: { 0: 'Keine Sterne', 1: '1 Stern', 2: '2 Sterne', 3: '3 Sterne', 4: '4 Sterne', 5: '5 Sterne' } });
|
||
}
|
||
if (block.type === 'cta') {
|
||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||
}
|
||
if (block.type === 'contact') {
|
||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||
field('E-Mail', 'email', block.props.email || '', { maxLength: 80 });
|
||
field('Anschrift', 'address', block.props.address || '', { multiline: true, maxLength: 200 });
|
||
}
|
||
if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } });
|
||
if (block.type === 'menu') {
|
||
field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches' }, help: 'Welche Einträge gezeigt werden. Gepflegt werden sie unter Menüs.' });
|
||
field('Menüart', 'layout', block.props.layout || 'horizontal', { choices: { horizontal: 'Waagerecht', vertical: 'Untereinander', burger: 'Aufklappbar (Burger)' }, help: 'Aufklappbar funktioniert ohne JavaScript. Untermenüs klappen in allen Arten per Klick auf, nicht beim Überfahren – das ist auf einem Touchgerät die einzige Art, die funktioniert.' });
|
||
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||
}
|
||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||
if (block.type === 'icon') {
|
||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||
}
|
||
if (block.type === 'social') {
|
||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||
field('Form', 'shape', block.props.shape, { choices: { plain: 'Nur Symbol', circle: 'Runde Fläche', square: 'Eckige Fläche' } });
|
||
field('Anordnung', 'layout', block.props.layout, { choices: { horizontal: 'Nebeneinander', vertical: 'Untereinander' } });
|
||
field('Namen anzeigen', 'labels', block.props.labels === true, { checkbox: true, help: 'Ohne Häkchen tragen die Links ihren Namen nur für Screenreader.' });
|
||
field('In neuem Tab öffnen', 'newTab', block.props.newTab !== false, { checkbox: true });
|
||
// Eine Auswahl aus den eingetragenen Profilen; keins gewählt heißt alle.
|
||
const chosen = new Set(block.props.networks ?? []);
|
||
const group = document.createElement('fieldset'); group.className = 'field-set';
|
||
const legend = document.createElement('legend'); legend.textContent = 'Nur diese Netzwerke'; group.append(legend);
|
||
for (const [key, name] of Object.entries(SOCIAL_NETWORKS)) {
|
||
const label = document.createElement('label'); label.className = 'check-label';
|
||
const box = document.createElement('input'); box.type = 'checkbox'; box.checked = chosen.has(key);
|
||
box.id = `social-${block.id}-${key}`; label.htmlFor = box.id;
|
||
box.addEventListener('change', () => {
|
||
history.change(doc => {
|
||
const current = locate(doc.blocks, block.id)?.block; if (!current) return;
|
||
const set = new Set(current.props.networks ?? []); box.checked ? set.add(key) : set.delete(key);
|
||
current.props.networks = Object.keys(SOCIAL_NETWORKS).filter(network => set.has(network));
|
||
});
|
||
renderTree(); controls(); schedulePreview();
|
||
});
|
||
label.append(box, ` ${name}`); group.append(label);
|
||
}
|
||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Ohne Auswahl erscheinen alle eingetragenen Profile.';
|
||
group.append(note); panel.append(group);
|
||
}
|
||
if (block.type === 'progress') {
|
||
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||
field('Wert (%)', 'value', block.props.value, { number: true, min: 0, max: 100, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
|
||
}
|
||
if (block.type === 'slider') {
|
||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, min: 0, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
|
||
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
|
||
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
|
||
}
|
||
field('Einrasten', 'snap', block.props.snap, { checkbox: true, help: 'Der Slider ist ein Scrollbereich – ohne JavaScript, ohne Autoplay. Blättern geht mit Finger, Trackpad, Mausrad und Tastatur.' });
|
||
}
|
||
if (block.type === 'embed') {
|
||
field('Gruppe', 'group', block.props.group, { choices: { external: 'Externe Inhalte', statistics: 'Statistik' }, help: 'Bestimmt, welche Einwilligung dieser Inhalt braucht. Vor der Einwilligung wird nichts geladen – kein Bild, kein Skript, keine Anfrage an den Anbieter.' });
|
||
field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Was hier eingebettet ist, in einem Satz. Screenreader lesen das als Namen des Rahmens vor, deshalb nicht leer lassen.' });
|
||
field('Anbieter', 'provider', block.props.provider, { maxLength: 80, help: 'Wer den Inhalt ausliefert, zum Beispiel Google Maps. Steht im Platzhalter, damit die Entscheidung eine Grundlage hat.' });
|
||
field('Adresse (HTTPS)', 'url', block.props.url, { help: 'Die Einbettungsadresse des Anbieters. Nur HTTPS, ohne Port. Dieser Host wird der Sicherheitsrichtlinie der Seite hinzugefügt – und zwar nur, wenn eingewilligt wurde.' });
|
||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||
}
|
||
if (block.type === 'html') {
|
||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||
const label = document.createElement('label'); label.htmlFor = 'own-html'; label.textContent = 'HTML';
|
||
const input = document.createElement('textarea');
|
||
input.id = 'own-html'; input.rows = 10; input.spellcheck = false; input.className = 'own-code';
|
||
input.value = block.props?.html ?? '';
|
||
input.readOnly = !mayScript;
|
||
input.addEventListener('change', () => {
|
||
history.change(doc => {
|
||
upgradeDocument(doc, 24);
|
||
const current = locate(doc.blocks, block.id)?.block;
|
||
if (!current) return;
|
||
current.props = { ...current.props, html: input.value };
|
||
});
|
||
changed();
|
||
});
|
||
const note = document.createElement('p'); note.className = 'field-help';
|
||
note.innerHTML = mayScript
|
||
? 'Markup, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. <strong><script></strong> gehört unter „Erweitert“ in das Feld „Eigenes Skript“, ein <strong><iframe></strong> in den Baustein „Externer Inhalt“ – nur der fragt vorher um Einwilligung. Die Vorschau zeigt das HTML so, wie die Seite es zeigt.'
|
||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||
panel.append(label, input, note);
|
||
}
|
||
if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' });
|
||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||
if (block.type === 'accordion') {
|
||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||
for (const [index, entry] of (block.children ?? []).entries()) {
|
||
const button = document.createElement('button'); button.type = 'button'; button.className = 'accordion-entry-choice';
|
||
button.textContent = `${index + 1}. ${entry.props?.title || 'Ohne Titel'}`;
|
||
button.setAttribute('aria-label', `Eintrag ${index + 1}: ${entry.props?.title || 'Ohne Titel'} bearbeiten`);
|
||
button.addEventListener('click', () => { propertyPanel = 'content'; choose(entry.id); });
|
||
entries.append(button);
|
||
}
|
||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||
panel.append(hint, entries, add);
|
||
}
|
||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' });
|
||
if (block.type === 'accordionitem') {
|
||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||
add.textContent = '+ Inhalt hinzufügen';
|
||
add.addEventListener('click', () => beginContainerInsert(block.id));
|
||
panel.append(add);
|
||
}
|
||
if (block.type === 'legal') {
|
||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||
}
|
||
if (block.type === 'postnav') {
|
||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
|
||
}
|
||
if (block.type === 'postlist') {
|
||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||
field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') });
|
||
field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' });
|
||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||
}
|
||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||
if (block.type === 'image') {
|
||
renderImageWidth(block, panel);
|
||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||
panel.append(imagePreview.element);
|
||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||
// Ohne eigene Wahl folgt das Bild seiner Galerie - dieselbe Regel wie in der Vorlage.
|
||
const holder = locate(history.document.blocks, block.id)?.parent;
|
||
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
||
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen';
|
||
remove.title = 'Bild aus diesem Inhalt entfernen. Die Datei bleibt in der Mediathek.';
|
||
remove.addEventListener('click', () => { actions.delete(block.id); $('#undo').focus(); }); panel.append(remove);
|
||
field('Bildadresse', 'src', block.props.src, { help: 'Lokale Datei oder HTTPS-Adresse. Über die Mediathek wird die passende Bildvariante automatisch gewählt.' });
|
||
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||
field('Originalbreite (px)', 'width', block.props.width, { number: true });
|
||
field('Originalhöhe (px)', 'height', block.props.height, { number: true, help: 'Abmessungen der Bilddatei. Die Anzeigegröße stellst du oben unter Bildbreite ein.' });
|
||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||
}
|
||
if (block.type === 'gallery') {
|
||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Gilt für alle Bilder der Galerie: ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Ein Bild mit eigener Einstellung unter „Beim Klick“ behält sie.' });
|
||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||
}
|
||
if (['gallery', 'logos'].includes(block.type)) {
|
||
const images = document.createElement('div'); images.className = 'selected-media-list';
|
||
for (const child of block.children || []) {
|
||
const item = document.createElement('div');
|
||
const title = child.props.alt || child.props.caption || 'Bild';
|
||
item.append(createMediaPreview({ src: child.props.sources?.[0]?.src || child.props.src, label: title }).element);
|
||
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'text-button'; edit.textContent = 'Bearbeiten'; edit.setAttribute('aria-label', `${title} bearbeiten`);
|
||
edit.addEventListener('click', () => choose(child.id));
|
||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Entfernen'; remove.setAttribute('aria-label', `${title} entfernen`);
|
||
remove.addEventListener('click', () => {
|
||
history.change(doc => { const found = locate(doc.blocks, child.id); if (found) found.siblings.splice(found.index, 1); }); changed(); $('#undo').focus();
|
||
});
|
||
item.append(edit, remove); images.append(item);
|
||
}
|
||
panel.append(images);
|
||
}
|
||
if (block.type === 'section') {
|
||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Own CSS and own script for one element.
|
||
*
|
||
* CSS is scoped to the element by the server, so it cannot reach the rest of the page; everyone
|
||
* with the editor role may use it. A script is arbitrary JavaScript on the published site, so it
|
||
* is shown read only unless the account is an administrator - and the server refuses a change
|
||
* either way, this is only the honest version of the same rule.
|
||
*/
|
||
function renderAdvanced(block, panel) {
|
||
const area = (labelText, key, value, help, rows) => {
|
||
const id = `own-${key}`;
|
||
const label = document.createElement('label'); label.htmlFor = id; label.textContent = labelText;
|
||
const input = document.createElement('textarea');
|
||
input.id = id; input.rows = rows; input.spellcheck = false; input.className = 'own-code';
|
||
input.value = value ?? '';
|
||
input.addEventListener('change', () => {
|
||
history.change(doc => {
|
||
upgradeDocument(doc, 13);
|
||
const current = locate(doc.blocks, block.id)?.block;
|
||
if (!current) return;
|
||
if ('' === input.value.trim()) delete current[key];
|
||
else current[key] = input.value;
|
||
});
|
||
changed();
|
||
});
|
||
const note = document.createElement('p'); note.className = 'field-help'; note.innerHTML = help;
|
||
panel.append(label, input, note);
|
||
return input;
|
||
};
|
||
|
||
area('Eigenes CSS', 'css', block.css, 'Eigenschaften für dieses Element, zum Beispiel <code>letter-spacing: 2px</code>. Verschachtelte Regeln beginnen mit <code>&</code>, etwa <code>&:hover { opacity: .8 }</code> oder <code>& .block-text { font-size: 20px }</code>. Der Server setzt den Selektor selbst, sodass die Regeln dieses Element nicht verlassen können. @-Regeln und andere Selektoren sind nicht vorgesehen.', 7);
|
||
|
||
const scriptInput = area('Eigenes Skript', 'script', block.script, mayScript
|
||
? '<strong>JavaScript auf der veröffentlichten Seite.</strong> Die Variable <code>element</code> zeigt auf dieses Element. Ausgeliefert wird es als eigene Datei, nicht inline, und in dieser Vorschau wird es nicht ausgeführt. Auf Seiten mit eigenem Skript erlaubt die Sicherheitsrichtlinie Skripte von dieser Website - überall sonst bleibt sie streng.'
|
||
: 'Eigenes Skript kann nur ein Administrator ändern, weil es Code im Browser jedes Besuchers ausführt. Der Inhalt ist hier nur zu sehen.', 7);
|
||
if (!mayScript) {
|
||
scriptInput.readOnly = true;
|
||
}
|
||
}
|
||
|
||
function renderImageWidth(block, panel) {
|
||
const group = document.createElement('div'); group.className = 'image-width-control';
|
||
const label = document.createElement('label'); label.htmlFor = 'image-width-percent'; label.textContent = 'Bildbreite (%)';
|
||
const row = document.createElement('div'); row.className = 'image-width-inputs';
|
||
const slider = document.createElement('input'); slider.type = 'range'; slider.setAttribute('aria-label', 'Bildbreite Schieberegler');
|
||
const exact = document.createElement('input'); exact.type = 'number'; exact.id = label.htmlFor;
|
||
for (const input of [slider, exact]) { input.min = '1'; input.max = '100'; input.step = '0.1'; input.setAttribute('aria-describedby', 'image-width-help image-width-state'); }
|
||
const state = document.createElement('p'); state.id = 'image-width-state'; state.className = 'field-help';
|
||
const help = document.createElement('p'); help.id = 'image-width-help'; help.className = 'field-help';
|
||
help.textContent = 'Anteil an der verfügbaren Breite der Sektion oder Spalte. Die Originalmaße der Datei bleiben erhalten.';
|
||
const error = document.createElement('p'); error.className = 'field-help image-width-error'; error.setAttribute('role', 'alert'); error.hidden = true;
|
||
error.textContent = 'Bitte 1 bis 100 eingeben. Die bisherige Breite bleibt bis dahin erhalten.';
|
||
const reset = document.createElement('button'); reset.type = 'button'; reset.id = 'image-width-reset'; reset.className = 'text-button'; reset.textContent = 'Breite zurücksetzen';
|
||
const sync = (keepInput = false) => {
|
||
const current = locate(history.document.blocks, block.id)?.block;
|
||
const { value, source } = effectiveStyle(current, device, 'width');
|
||
const percent = typeof value === 'string' && /^\d+(?:\.\d+)?%$/.test(value) ? parseFloat(value) : null;
|
||
if (!keepInput) exact.value = percent ?? '';
|
||
exact.placeholder = value && percent === null ? value : '100';
|
||
slider.value = percent ?? 100;
|
||
slider.setAttribute('aria-valuetext', percent !== null ? `${percent} Prozent` : `Aktuell ${value || 'automatisch'}. Prozentwert wählen.`);
|
||
state.textContent = `${devices[device]} · ${source === device ? 'Eigener Wert' : source ? `Von ${devices[source]} übernommen` : 'Automatische Breite'}${value ? ` (${value})` : ''}`;
|
||
const maximum = effectiveStyle(current, device, 'maxWidth').value;
|
||
if (maximum && maximum !== 'none') state.textContent += `. Maximale Breite: ${maximum} (Design → Größe).`;
|
||
};
|
||
const apply = (value, keepInput = false) => {
|
||
error.hidden = true; exact.removeAttribute('aria-invalid');
|
||
history.change(doc => setStyle(doc, block.id, device, 'width', value), `image-width:${block.id}:${device}`);
|
||
controls(); schedulePreview(); sync(keepInput);
|
||
};
|
||
slider.addEventListener('input', () => apply(`${slider.value}%`));
|
||
exact.addEventListener('input', () => {
|
||
if (exact.validity.badInput || (exact.value !== '' && !exact.checkValidity())) { error.hidden = false; exact.setAttribute('aria-invalid', 'true'); return; }
|
||
apply(exact.value === '' ? '' : `${Number(exact.value)}%`, true);
|
||
});
|
||
// A whole drag or keyboard edit is one undo step, independent of preview requests.
|
||
slider.addEventListener('change', () => { history.mergeKey = null; });
|
||
exact.addEventListener('blur', () => { history.mergeKey = null; if (error.hidden) sync(); });
|
||
reset.addEventListener('click', () => { history.mergeKey = null; apply(''); history.mergeKey = null; });
|
||
row.append(slider, exact); group.append(label, row, state, help, error, reset); panel.append(group); sync();
|
||
}
|
||
|
||
function renderDesign(block, panel) {
|
||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||
if (designSchema && designPanel) {
|
||
designPanel(panel);
|
||
} else {
|
||
panel.append(Object.assign(document.createElement('p'), { className: 'panel-note', textContent: 'Gestaltungsoptionen werden geladen …' }));
|
||
}
|
||
// Entrance animations and the preset effects have no equivalent in the free system: they are
|
||
// keyframes, not declarations. They stay available for every block.
|
||
const effects = document.createElement('details'); effects.className = 'design-group';
|
||
const effectsSummary = document.createElement('summary'); effectsSummary.textContent = 'Bewegung und Effekte';
|
||
const effectsBody = document.createElement('div'); effectsBody.className = 'design-group-body';
|
||
effects.append(effectsSummary, effectsBody);
|
||
renderLegacyEffects(block, effectsBody);
|
||
panel.append(effects);
|
||
|
||
// The token values are superseded by the free panel and only shown where a page still has them.
|
||
if (!block.layout?.design) return;
|
||
const details = document.createElement('details'); details.className = 'design-group';
|
||
const summary = document.createElement('summary'); summary.textContent = 'Klassische Werte dieser Seite';
|
||
const body = document.createElement('div'); body.className = 'design-group-body';
|
||
details.append(summary, body);
|
||
renderLegacyTokens(block, body);
|
||
panel.append(details);
|
||
}
|
||
|
||
function renderLegacyTokens(block, panel) {
|
||
// Token based values from documents written before schema v10. Shown only while a block
|
||
// still carries them, so older pages stay editable without cluttering the new panel.
|
||
const hint = document.createElement('p'); hint.className = 'design-scope';
|
||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 767 px'}`;
|
||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
|
||
panel.append(hint, help);
|
||
for (const [key, token] of Object.entries(designTokens)) {
|
||
if (!token.types.includes(block.type)) continue;
|
||
const label = document.createElement('label'); label.textContent = token.label; label.htmlFor = `design-${key}`;
|
||
const select = document.createElement('select'); select.id = label.htmlFor;
|
||
const previous = device === 'mobile' ? effectiveDesign(block, 'tablet', key) : device === 'tablet' ? effectiveDesign(block, 'desktop', key) : {};
|
||
const inherited = previous.value ? `${token.values[previous.value][0]} von ${devices[previous.source]}` : 'Theme-Standard';
|
||
for (const [value, text] of [['', `Übernehmen: ${inherited}`], ...Object.entries(token.values).map(([value, [label]]) => [value, label])]) {
|
||
const option = document.createElement('option'); option.value = value; option.textContent = text; select.append(option);
|
||
}
|
||
select.value = block.layout?.design?.[device]?.[key] || '';
|
||
select.addEventListener('change', () => {
|
||
history.change(doc => setDesign(doc, selected, device, key, select.value));
|
||
controls(); schedulePreview();
|
||
});
|
||
panel.append(label, select);
|
||
}
|
||
const reset = document.createElement('button'); reset.className = 'secondary design-reset'; reset.textContent = `${devices[device]}-Werte zurücksetzen`;
|
||
reset.addEventListener('click', () => {
|
||
history.change(doc => { const current = locate(doc.blocks, selected).block; for (const key of Object.keys(current.layout?.design?.[device] || {})) setDesign(doc, selected, device, key, ''); });
|
||
changed();
|
||
}); panel.append(reset);
|
||
}
|
||
|
||
function renderLegacyEffects(block, panel) {
|
||
for (const [key, label, choices] of [
|
||
['shadow', 'Schatten', { none: 'Keiner', soft: 'Weich', strong: 'Kräftig' }],
|
||
['hover', 'Hover-Effekt', {
|
||
none: 'Keiner', lift: 'Leicht anheben', sink: 'Leicht absenken', zoom: 'Bild vergrößern',
|
||
glow: 'Leuchten', tilt: 'Leicht kippen', 'grow-border': 'Rahmen einfahren',
|
||
}],
|
||
['entrance', 'Einblendung beim Erscheinen', {
|
||
none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten', sink: 'Von oben', left: 'Von links',
|
||
right: 'Von rechts', zoom: 'Heranzoomen', flip: 'Kippen', blur: 'Aus der Unschärfe',
|
||
}],
|
||
['delay', 'Verzögerung der Einblendung', { none: 'Keine', short: '0,15 s', medium: '0,3 s', long: '0,6 s' }],
|
||
]) {
|
||
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.htmlFor = `effect-${key}`;
|
||
const select = document.createElement('select'); select.id = labelEl.htmlFor;
|
||
for (const [value, title] of Object.entries(choices)) { const option = document.createElement('option'); option.value = value; option.textContent = title; select.append(option); }
|
||
select.value = block.layout?.effects?.[key] || 'none';
|
||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||
panel.append(labelEl, select);
|
||
}
|
||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||
}
|
||
|
||
/**
|
||
* Die Felder eines Plugin-Bausteins, aus dem Schema seines Manifests erzeugt. Dieselbe
|
||
* Feldfabrik wie für Kernbausteine; nur ein Bild braucht die Mediathek.
|
||
*/
|
||
function renderPluginFields(block, panel, plugin, field) {
|
||
if (plugin.description) { const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = plugin.description; panel.append(hint); }
|
||
for (const [key, schema] of Object.entries(plugin.props || {})) {
|
||
const value = block.props?.[key] ?? schema.default;
|
||
const options = { help: schema.help || undefined };
|
||
if (schema.kind === 'textarea') field(schema.label, key, value, { ...options, multiline: true, maxLength: schema.maxLength });
|
||
else if (schema.kind === 'number') field(schema.label, key, value, { ...options, number: true, min: schema.min, max: schema.max });
|
||
else if (schema.kind === 'boolean') field(schema.label, key, value === true, { ...options, checkbox: true });
|
||
else if (schema.kind === 'select') field(schema.label, key, value, { ...options, choices: schema.choices });
|
||
else if (schema.kind === 'media') {
|
||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = schema.label; wrap.append(label);
|
||
const preview = createMediaPreview({ mediaId: value || '' }); wrap.append(preview.element);
|
||
const set = id => { history.change(doc => { const current = locate(doc.blocks, block.id)?.block; if (current) (current.props ??= {})[key] = id; }); renderProperties(); changed(); };
|
||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild aus Mediathek wählen';
|
||
pick.addEventListener('click', () => openMedia({ multiple: false, onChoose: items => { if (items[0]?.id) set(items[0].id); } }));
|
||
wrap.append(pick);
|
||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||
panel.append(wrap);
|
||
}
|
||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' });
|
||
}
|
||
}
|
||
|
||
function chooseMedia(multiple) {
|
||
const target = selected;
|
||
openMedia({ multiple, onChoose: items => {
|
||
if (!items.length || busy) return;
|
||
const id = history.change(doc => insertMedia(doc, target, items));
|
||
if (id === null) { notify('Hier lassen sich keine Bilder einfügen – wähle eine Sektion, Spalte oder Galerie.', true); return; }
|
||
selected = id; changed();
|
||
} });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Inline text editing.
|
||
//
|
||
// The preview runs no script of its own (the iframe is sandboxed without allow-scripts), but the
|
||
// editor can reach into its document. Editing therefore happens from here: the element is made
|
||
// editable, typing goes straight into the model, and the preview is left alone until editing ends
|
||
// so the caret is never pulled away mid-sentence.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/** Block type to the element inside it that carries the text, and the property it belongs to. */
|
||
const INLINE_TEXT = {
|
||
heading: { selector: null, prop: 'text' },
|
||
text: { selector: null, prop: 'text', multiline: true },
|
||
quote: { selector: 'blockquote p', prop: 'text', multiline: true },
|
||
cta: { selector: 'h2', prop: 'title' },
|
||
iconcard: { selector: 'h3', prop: 'title' },
|
||
accordionitem: { selector: 'summary', prop: 'title' },
|
||
tabitem: { selector: '.tabs-heading', prop: 'title' },
|
||
stat: { selector: 'strong', prop: 'value' },
|
||
};
|
||
|
||
let inlineEdit = null;
|
||
|
||
function inlineTarget(blockId) {
|
||
const block = locate(history.document.blocks, blockId)?.block;
|
||
const rule = block && INLINE_TEXT[block.type];
|
||
if (!rule) return null;
|
||
const root = $('#preview').contentDocument?.getElementById(blockId);
|
||
if (!root) return null;
|
||
const element = rule.selector ? root.querySelector(rule.selector) : root;
|
||
return element ? { block, rule, element } : null;
|
||
}
|
||
|
||
function startInlineEdit(blockId) {
|
||
if (inlineEdit?.blockId === blockId) return;
|
||
stopInlineEdit();
|
||
const target = inlineTarget(blockId);
|
||
if (!target || busy) return;
|
||
const { rule, element } = target;
|
||
// A v12 text block keeps its markup while editing in place; everything else stays plain.
|
||
const formatted = 'text' === target.block.type && (history.document.schemaVersion ?? 1) >= 12;
|
||
element.setAttribute('contenteditable', formatted ? 'true' : 'plaintext-only');
|
||
element.spellcheck = true;
|
||
element.classList.add('editor-inline');
|
||
element.focus();
|
||
// Put the caret at the end instead of selecting everything, so typing appends.
|
||
const range = element.ownerDocument.createRange();
|
||
range.selectNodeContents(element);
|
||
range.collapse(false);
|
||
const selection = element.ownerDocument.getSelection();
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
|
||
const onInput = () => {
|
||
const value = formatted
|
||
? element.innerHTML
|
||
: element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
|
||
history.change(doc => {
|
||
const current = locate(doc.blocks, blockId)?.block;
|
||
if (current) current.props[rule.prop] = (rule.multiline || formatted) ? value : value.replace(/\s*\n+\s*/g, ' ');
|
||
}, `inline-${blockId}-${rule.prop}`);
|
||
controls();
|
||
};
|
||
const onKey = event => {
|
||
if (event.key === 'Escape') { event.preventDefault(); stopInlineEdit(); }
|
||
if (event.key === 'Enter' && !rule.multiline) { event.preventDefault(); stopInlineEdit(); }
|
||
};
|
||
element.addEventListener('input', onInput);
|
||
element.addEventListener('keydown', onKey);
|
||
element.addEventListener('blur', stopInlineEdit, { once: true });
|
||
inlineEdit = { blockId, element, onInput, onKey };
|
||
$('#preview-status').textContent = 'Direktbearbeitung · Escape beendet sie, Änderungen stehen sofort im Verlauf.';
|
||
}
|
||
|
||
function stopInlineEdit() {
|
||
if (!inlineEdit) return;
|
||
const { element, onInput, onKey } = inlineEdit;
|
||
inlineEdit = null;
|
||
element.removeEventListener('input', onInput);
|
||
element.removeEventListener('keydown', onKey);
|
||
element.removeAttribute('contenteditable');
|
||
element.classList.remove('editor-inline');
|
||
history.mergeKey = null;
|
||
// One refresh at the end, so the stored document and the preview agree again.
|
||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||
}
|
||
|
||
function resizePreview() {
|
||
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||
const available = Math.max(100, container.clientWidth - padding);
|
||
const width = { desktop: Math.max(1240, available), tablet: 768, mobile: 390 }[device];
|
||
const scale = Math.min(1, available / width);
|
||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||
previewScale = scale;
|
||
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
|
||
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
|
||
frame.title = `Vorschau ${devices[device]} · ${width} px`;
|
||
overlay?.sync();
|
||
}
|
||
|
||
function schedulePreview() {
|
||
// Replacing the iframe content would destroy the caret, so editing wins until it ends.
|
||
if (inlineEdit) return;
|
||
clearTimeout(previewTimer);
|
||
// Invalidate an in-flight response immediately, including during the debounce window.
|
||
previewSequence++;
|
||
previewController?.abort();
|
||
$('#preview-status').textContent = 'Vorschau wird aktualisiert …';
|
||
previewTimer = setTimeout(updatePreview, 250);
|
||
}
|
||
|
||
async function updatePreview() {
|
||
const sequence = previewSequence;
|
||
const controller = new AbortController(); previewController = controller;
|
||
try {
|
||
const html = await request('/preview', { document: history.document }, controller.signal);
|
||
if (sequence !== previewSequence) return;
|
||
const frame = $('#preview');
|
||
const top = frame.contentWindow?.scrollY || 0;
|
||
let angewandt = false;
|
||
// Die Gestaltung muss sitzen, sobald der Kopf des Dokuments geparst ist. Vorher hing das an
|
||
// frame.onload - und das feuert erst, wenn das letzte Bild geladen ist. Auf einer Seite mit
|
||
// sechzehn Bildern stand die Vorschau dadurch sekundenlang ohne jede Gestaltung da: schwarze
|
||
// Knöpfe statt roter, kein Hero, keine Farben. Genau das hat Umut im Bild gesehen.
|
||
// Welches Dokument gerade im Rahmen steht. Ohne diesen Vergleich wird beim Warten auf das
|
||
// neue Dokument das alte gestaltet - es hat dieselben Kennzeichen -, der Rahmen wird sichtbar
|
||
// geschaltet, und gleich darauf ersetzt srcdoc den Inhalt durch den ungestalteten neuen.
|
||
const altesDokument = frame.contentDocument;
|
||
const anwenden = () => {
|
||
if (angewandt || sequence !== previewSequence) return;
|
||
const doc = frame.contentDocument;
|
||
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
|
||
angewandt = true;
|
||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = `/assets/preview.css?v=${new URL(import.meta.url).searchParams.get('v') ?? 'dev'}`;
|
||
// Register before insertion: cached loads (or errors) can precede DOMContentLoaded.
|
||
const styleReady = new Promise(resolve => {
|
||
style.addEventListener('load', () => resolve(true), { once: true });
|
||
style.addEventListener('error', () => resolve(false), { once: true });
|
||
});
|
||
doc.head.append(style);
|
||
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
|
||
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
|
||
// preview shows exactly what the published page shows, without a single blocked style.
|
||
const generated = doc.getElementById('kant-styles')?.content.textContent ?? '';
|
||
if (generated.trim()) {
|
||
const sheet = new doc.defaultView.CSSStyleSheet();
|
||
sheet.replaceSync(generated);
|
||
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
||
}
|
||
doc.addEventListener('click', event => {
|
||
event.preventDefault();
|
||
const menuSummary = event.target.closest('.block-menu summary');
|
||
if (menuSummary && !busy && !inlineEdit) {
|
||
menuSummary.parentElement.open = !menuSummary.parentElement.open;
|
||
}
|
||
const insert = event.target.closest('[data-editor-target]');
|
||
if (insert && !busy && history) {
|
||
stopInlineEdit();
|
||
const id = insert.dataset.editorTarget;
|
||
if (!locate(history.document.blocks, id)) return;
|
||
choose(id, false);
|
||
if (insert.hasAttribute('data-editor-add-entry')) {
|
||
addAccordionEntry(id);
|
||
} else if (insert.hasAttribute('data-editor-row')) {
|
||
setPendingInsert(null);
|
||
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
|
||
propertyPanel = 'content'; changed();
|
||
} else if (locate(history.document.blocks, id).block.type === 'row') {
|
||
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
|
||
changed();
|
||
} else beginContainerInsert(id);
|
||
return;
|
||
}
|
||
if (inlineEdit) return;
|
||
const element = event.target.closest('[id]');
|
||
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||
});
|
||
doc.addEventListener('dblclick', event => {
|
||
const element = event.target.closest('[id]');
|
||
if (!element) return;
|
||
const block = locate(history.document.blocks, element.id)?.block;
|
||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||
});
|
||
// Right click: the element's actions where the pointer is, as in Flatsome.
|
||
doc.addEventListener('contextmenu', event => {
|
||
const element = event.target.closest('[id]');
|
||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
|
||
event.preventDefault();
|
||
choose(element.id, false);
|
||
openContextMenu(element.id, event.clientX, event.clientY);
|
||
});
|
||
doc.addEventListener('mousedown', event => { if (event.button === 0) closeContextMenu(); });
|
||
doc.addEventListener('keydown', onKey);
|
||
// The preview runs no script, so the scrolled state of the header is marked from here -
|
||
// the same rule scrolled.js applies on the public page.
|
||
const markScrolled = () => doc.documentElement.classList.toggle('is-scrolled', (doc.defaultView?.scrollY ?? 0) > 40);
|
||
doc.defaultView?.addEventListener('scroll', markScrolled, { passive: true });
|
||
markScrolled();
|
||
doc.addEventListener('submit', event => event.preventDefault());
|
||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||
const finish = () => {
|
||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||
decorateContainers(doc, history.document);
|
||
overlay?.attach();
|
||
styleReady.then(loaded => {
|
||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||
frame.contentWindow.scrollTo(0, top);
|
||
highlight(revealAfterPreview === selected); revealAfterPreview = null;
|
||
markInsertTarget();
|
||
frame.style.visibility = '';
|
||
showMotionStatus();
|
||
playPreview();
|
||
$('#preview-status').textContent = loaded
|
||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
|
||
});
|
||
};
|
||
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', finish, { once: true }); else finish();
|
||
};
|
||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||
frame.style.visibility = 'hidden';
|
||
stopPlayback();
|
||
frame.srcdoc = html;
|
||
// Sobald der Kopf da ist, wird gestaltet - geprüft Bild für Bild statt auf ein Ereignis zu
|
||
// warten, das es für srcdoc nicht zuverlässig gibt.
|
||
const warten = () => {
|
||
if (angewandt || sequence !== previewSequence) return;
|
||
anwenden();
|
||
if (!angewandt) requestAnimationFrame(warten);
|
||
};
|
||
requestAnimationFrame(warten);
|
||
// Das Netz darunter ist das Ladeereignis - und zwar ohne Uhr: bringt auch das fertig geladene
|
||
// Dokument keine Gestaltung mit, stimmt etwas anderes nicht, und dann ist eine unfertige
|
||
// Vorschau immer noch besser als eine unsichtbare.
|
||
frame.onload = () => {
|
||
anwenden();
|
||
if (!angewandt) {
|
||
frame.style.visibility = '';
|
||
$('#preview-status').textContent = 'Vorschau ohne Gestaltung - bitte die Seite neu laden.';
|
||
}
|
||
};
|
||
} catch (error) {
|
||
if (error.name === 'AbortError' || sequence !== previewSequence) return;
|
||
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
function changed() {
|
||
if (!locate(history.document.blocks, selected)) selected = null;
|
||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||
syncView();
|
||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||
}
|
||
|
||
async function loadDesignSchema() {
|
||
try {
|
||
const response = await fetch(adminBase + '/api/design', { credentials: 'same-origin' });
|
||
if (!response.ok) return;
|
||
designSchema = await response.json();
|
||
designPanel = createDesignPanel({
|
||
schema: designSchema,
|
||
onChange: options => { controls(); renderTree(); schedulePreview(); if (!options?.keepPanel) renderProperties(); },
|
||
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
|
||
getBlock: () => locate(history.document.blocks, selected)?.block,
|
||
getDevice: () => device,
|
||
onDevice: setViewport,
|
||
getSelection: () => selected,
|
||
getHistory: () => history,
|
||
getAreaKind: () => root.dataset.kind,
|
||
});
|
||
} catch {
|
||
// The editor stays usable without it; the panel says so.
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
busy = true; controls();
|
||
try {
|
||
const data = await request(editingArea ? '/document' : '');
|
||
if (!designTokens) {
|
||
const response = await fetch('/assets/design-tokens.json');
|
||
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
|
||
designTokens = await response.json();
|
||
}
|
||
if (!designSchema) await loadDesignSchema();
|
||
history = new EditorHistory(data.document);
|
||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
|
||
// bekommt dafür eine eigene Gruppe.
|
||
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
|
||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||
} catch (error) { notify(error.message, true); }
|
||
finally { busy = false; controls(); }
|
||
}
|
||
|
||
async function save(publish = false) {
|
||
if (!history || busy || conflict || (publish && history.dirty)) return;
|
||
const snapshot = clone(history.document);
|
||
busy = true; controls(); notify();
|
||
try {
|
||
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
|
||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
|
||
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
|
||
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
|
||
history.adopt(data.document);
|
||
history.markSaved(data.document);
|
||
renderTree(); renderProperties(); schedulePreview();
|
||
} else history.markSaved(snapshot);
|
||
notify();
|
||
} catch (error) { notify(error.message, true); }
|
||
finally { busy = false; controls(); }
|
||
}
|
||
|
||
/**
|
||
* Adds an element. Without a marked insertion point this behaves as it always did - behind the
|
||
* selection. With one, set by the plus signs in the preview, it lands exactly there.
|
||
*/
|
||
function addElement(type) {
|
||
if (busy || !history) return;
|
||
const at = pendingInsert;
|
||
setPendingInsert(null);
|
||
selected = history.change(doc => at && type !== 'section'
|
||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||
: insertBlock(doc, type, at?.parentId ?? selected));
|
||
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
|
||
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
|
||
changed();
|
||
}
|
||
|
||
function buttonControls(block, panel) {
|
||
renderButtonControls(panel, { block, device, schema: designSchema, apply: (values, focusId) => {
|
||
history.change(doc => setButtonDesign(doc, block.id, device, values)); changed();
|
||
document.getElementById(focusId)?.focus({ preventScroll: true });
|
||
} });
|
||
}
|
||
|
||
function beginContainerInsert(id) {
|
||
const block = locate(history.document.blocks, id)?.block;
|
||
if (!block || busy) return;
|
||
propertyPanel = 'content';
|
||
choose(id, false);
|
||
setPendingInsert({ parentId: id, index: block.children?.length ?? 0 });
|
||
}
|
||
|
||
function addAccordionEntry(id) {
|
||
const block = locate(history.document.blocks, id)?.block;
|
||
if (busy || block?.type !== 'accordion') return;
|
||
setPendingInsert(null);
|
||
selected = history.change(doc => insertBlockAt(doc, 'accordionitem', id, block.children?.length ?? 0));
|
||
collapsed.delete(id); propertyPanel = 'content'; changed();
|
||
$('#property-title')?.focus();
|
||
}
|
||
|
||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||
function setPendingInsert(target) {
|
||
pendingInsert = target;
|
||
updateLibrary();
|
||
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
|
||
$('#insert-hint').hidden = !target;
|
||
markInsertTarget();
|
||
if (target) {
|
||
setView('elements');
|
||
$('#library-search').value = ''; filterLibrary();
|
||
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
|
||
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
|
||
$('#library-search').focus({ preventScroll: true });
|
||
}
|
||
}
|
||
|
||
function markInsertTarget() {
|
||
const doc = $('#preview').contentDocument;
|
||
doc?.querySelectorAll('.editor-insert-target').forEach(node => node.classList.remove('editor-insert-target'));
|
||
if (pendingInsert?.parentId) doc?.getElementById(pendingInsert.parentId)?.classList.add('editor-insert-target');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// The toolbox.
|
||
//
|
||
// It is built here rather than listed in the template because it has to answer a question that
|
||
// only the current selection can answer: what fits where the next element would go. A flat list
|
||
// of every type offered a row on an empty page and a column next to nothing - the structure the
|
||
// editor describes in its own hint was not the structure the buttons followed.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/** Die Seitenvorlagen der Plugins, hinter den eingebauten, mit demselben Knopf. */
|
||
function buildPluginPresets() {
|
||
const presets = $('#presets');
|
||
presets.querySelectorAll('[data-plugin-preset]').forEach(button => button.remove());
|
||
for (const preset of Object.values(PLUGIN_PRESETS)) {
|
||
const button = document.createElement('button'); button.type = 'button'; button.dataset.preset = preset.id; button.dataset.pluginPreset = '';
|
||
button.title = preset.description ? `${preset.description} (${preset.plugin})` : preset.plugin;
|
||
const icon = document.createElement('span'); icon.setAttribute('aria-hidden', 'true'); icon.textContent = '⬡';
|
||
button.append(icon, document.createTextNode(` ${preset.label}`));
|
||
presets.append(button);
|
||
}
|
||
controls();
|
||
}
|
||
|
||
/** Die Zeichnung für einen Plugin-Baustein im Werkzeugkasten: ein Steckstück. */
|
||
const PLUGIN_PREVIEW = '<svg viewBox="0 0 64 40" aria-hidden="true"><path d="M20 8h-8v24h40V8h-8v4a4 4 0 0 1-8 0V8h-8v4a4 4 0 0 1-8 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>';
|
||
|
||
function buildLibrary() {
|
||
const library = $('#library');
|
||
library.replaceChildren();
|
||
for (const category of CATEGORIES) {
|
||
const group = document.createElement('div'); group.className = 'library-group';
|
||
const heading = document.createElement('h4'); heading.className = 'library-group-title'; heading.textContent = category.name;
|
||
group.append(heading);
|
||
if (category.hint) {
|
||
const hint = document.createElement('p'); hint.className = 'library-group-hint'; hint.textContent = category.hint;
|
||
group.append(hint);
|
||
}
|
||
const list = document.createElement('div'); list.className = 'library-buttons';
|
||
for (const type of category.types) {
|
||
// A row without columns is an empty box, so it is offered by column count instead - the
|
||
// shape people actually want when they reach for a row.
|
||
for (const columns of 'row' === type ? ROW_COLUMNS : [null]) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button'; button.draggable = true;
|
||
button.dataset.add = type;
|
||
if (columns) button.dataset.row = String(columns);
|
||
const name = columns ? `${columns} Spalten` : labels[type];
|
||
// What is searched for: the name, the internal type and the group it sits in. Someone
|
||
// looking for "slider" should not have to know it is filed under "Gruppen".
|
||
button.dataset.search = `${name} ${columns ? labels[type] + ' ' : ''}${type} ${category.name}`.toLocaleLowerCase('de');
|
||
const picture = document.createElement('span');
|
||
picture.className = 'library-preview';
|
||
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
||
picture.innerHTML = columns ? rowPreview(columns) : (preview(type) || PLUGIN_PREVIEW);
|
||
const text = document.createElement('span');
|
||
text.className = 'library-name';
|
||
text.textContent = name;
|
||
button.append(picture, text);
|
||
list.append(button);
|
||
}
|
||
}
|
||
group.append(list);
|
||
library.append(list.children.length ? group : document.createComment('leer'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Greys out what cannot go where the next element would land, and says where it will land when
|
||
* it can. The answer comes from the same function the click follows, so the two cannot disagree.
|
||
*/
|
||
function updateLibrary() {
|
||
const ready = Boolean(history);
|
||
for (const button of $('#library').querySelectorAll('button')) {
|
||
const type = button.dataset.add;
|
||
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
|
||
// A section always has a place - on the page, after the area the insert point sits in.
|
||
const target = pendingInsert && type !== 'section'
|
||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||
: insertionTarget(history.document, pendingInsert?.parentId ?? selected, type);
|
||
button.disabled = !target;
|
||
if (!target) {
|
||
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
|
||
? 'Wähle zuerst einen Akkordeon-Eintrag, um darin Inhalte einzufügen.' : whyNot(type);
|
||
continue;
|
||
}
|
||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||
}
|
||
}
|
||
|
||
/** Filters the toolbox. A group without a hit disappears, so the list does not keep its gaps. */
|
||
function filterLibrary() {
|
||
const term = $('#library-search').value.trim().toLocaleLowerCase('de');
|
||
let hits = 0;
|
||
for (const group of $('#library').querySelectorAll('.library-group')) {
|
||
let visible = 0;
|
||
for (const button of group.querySelectorAll('button')) {
|
||
const hit = '' === term || (button.dataset.search ?? '').includes(term);
|
||
button.hidden = !hit;
|
||
if (hit) visible++;
|
||
}
|
||
group.hidden = 0 === visible;
|
||
hits += visible;
|
||
}
|
||
$('#library-found').textContent = '' === term ? '' : (0 === hits ? 'Kein Element mit diesem Namen.' : `${hits} von ${$('#library').querySelectorAll('button').length}`);
|
||
}
|
||
$('#library-search').addEventListener('input', filterLibrary);
|
||
|
||
$('#library').addEventListener('click', event => {
|
||
const button = event.target.closest('[data-add]');
|
||
if (!button || button.disabled) return;
|
||
if (button.dataset.row) {
|
||
if (busy || !history) return;
|
||
const at = pendingInsert;
|
||
setPendingInsert(null);
|
||
const id = history.change(doc => at
|
||
? insertRowAt(doc, button.dataset.row, at.parentId, at.index)
|
||
: insertRow(doc, button.dataset.row, selected));
|
||
if (id) { selected = id; propertyPanel = 'design'; changed(); }
|
||
return;
|
||
}
|
||
addElement(button.dataset.add);
|
||
});
|
||
$('#cancel-insert').addEventListener('click', () => { setPendingInsert(null); if (selected) setView('element'); });
|
||
// The tabs at the top of the sidebar. "Seite" also drops the selection: its settings are what
|
||
// the page shows when nothing is chosen.
|
||
for (const tab of document.querySelectorAll('.sidebar-nav [role=tab]')) tab.addEventListener('click', () => {
|
||
if (busy || !history) return;
|
||
if (tab.dataset.view === 'page') { setView('page'); choose(null); return; }
|
||
setView(tab.dataset.view);
|
||
});
|
||
$('#go-parent').addEventListener('click', () => actions.parent(selected));
|
||
$('#close-element').addEventListener('click', () => choose(null));
|
||
// The plus next to the name: into a container, otherwise behind the element.
|
||
$('#add-here').addEventListener('click', () => {
|
||
const item = locate(history.document.blocks, selected);
|
||
if (!item || busy) return;
|
||
if (isContainer(item.block) && item.block.type !== 'accordion') beginContainerInsert(item.block.id);
|
||
else setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + 1 });
|
||
});
|
||
$('#presets').addEventListener('click', event => {
|
||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||
if (!name || busy || !history) return;
|
||
selected = history.change(doc => insertPreset(doc, name, selected)); propertyPanel = 'content'; changed();
|
||
});
|
||
$('#structure').addEventListener('click', event => {
|
||
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
|
||
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
|
||
const edit = event.target.closest('[data-edit-block]')?.dataset.editBlock;
|
||
if (edit) { setView('element'); choose(edit); return; }
|
||
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
|
||
});
|
||
$('#structure').addEventListener('dblclick', event => {
|
||
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||
if (id && !busy) { setView('element'); choose(id); }
|
||
});
|
||
$('#tree-search').addEventListener('input', renderTree);
|
||
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
|
||
$('#collapse-all').addEventListener('click', () => {
|
||
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
|
||
visit(history.document.blocks); renderTree();
|
||
});
|
||
document.querySelectorAll('[data-panel]').forEach(button => button.addEventListener('click', () => { propertyPanel = button.dataset.panel; history.mergeKey = null; renderProperties(); controls(); }));
|
||
$('#structure').addEventListener('dragstart', event => {
|
||
if (busy) { event.preventDefault(); return; }
|
||
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||
if (!id) return;
|
||
event.dataTransfer.setData('text/plain', id);
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
const block = locate(history.document.blocks, id)?.block;
|
||
if (block) overlay.startDrag({ kind: 'move', type: block.type, block });
|
||
});
|
||
$('#structure').addEventListener('dragend', () => overlay.endDrag());
|
||
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
|
||
$('#structure').addEventListener('drop', event => {
|
||
event.preventDefault(); if (busy) return;
|
||
const target = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||
const source = event.dataTransfer.getData('text/plain');
|
||
if (!target || !source) return;
|
||
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
|
||
selected = source; changed();
|
||
});
|
||
// The actions a block offers. The sidebar buttons and the toolbar in the preview are two ways
|
||
// into the same functions, so the two can never drift apart.
|
||
const actions = {
|
||
duplicate: id => { selected = history.change(doc => duplicateBlock(doc, id)); changed(); },
|
||
delete: id => {
|
||
selected = history.change(doc => {
|
||
const item = locate(doc.blocks, id);
|
||
if (!item) return null;
|
||
item.siblings.splice(item.index, 1);
|
||
return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null;
|
||
});
|
||
changed();
|
||
},
|
||
move: (id, direction) => {
|
||
history.change(doc => {
|
||
const item = locate(doc.blocks, id);
|
||
const next = item.index + direction;
|
||
if (next < 0 || next >= item.siblings.length) return;
|
||
[item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]];
|
||
});
|
||
selected = id; changed();
|
||
},
|
||
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// The context menu of the preview. Lives in the editor document, so it is drawn at full size
|
||
// above a scaled preview; the frame coordinates are converted once, here.
|
||
// ---------------------------------------------------------------------------------------------
|
||
function openContextMenu(id, frameX, frameY) {
|
||
const item = locate(history.document.blocks, id);
|
||
if (!item) return;
|
||
const menu = $('#context-menu');
|
||
const label = blockLabel(item.block);
|
||
const sibling = dropList(history.document, item.parent?.id ?? null, item.block);
|
||
const entries = [
|
||
{ text: `${label} bearbeiten`, run: () => { setView('element'); choose(id, false); } },
|
||
isContainer(item.block) && item.block.type !== 'accordion' ? { text: 'Element hinein', run: () => beginContainerInsert(id) } : null,
|
||
sibling ? { text: 'Element davor', run: () => setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index }) } : null,
|
||
sibling ? { text: 'Element dahinter', run: () => setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + 1 }) } : null,
|
||
{ text: 'Duplizieren', run: () => actions.duplicate(id) },
|
||
item.index > 0 ? { text: 'Nach oben', run: () => actions.move(id, -1) } : null,
|
||
item.index < item.siblings.length - 1 ? { text: 'Nach unten', run: () => actions.move(id, 1) } : null,
|
||
item.parent ? { text: `Übergeordnet: ${blockLabel(item.parent)}`, run: () => actions.parent(id) } : null,
|
||
{ text: 'Löschen', danger: true, run: () => actions.delete(id) },
|
||
].filter(Boolean);
|
||
menu.replaceChildren(...entries.map(entry => {
|
||
const button = document.createElement('button'); button.type = 'button'; button.setAttribute('role', 'menuitem');
|
||
button.textContent = entry.text; if (entry.danger) button.className = 'danger-text';
|
||
button.addEventListener('click', () => { closeContextMenu(); entry.run(); });
|
||
return button;
|
||
}));
|
||
const frame = $('#preview').getBoundingClientRect();
|
||
menu.hidden = false;
|
||
const x = frame.left + frameX * previewScale, y = frame.top + frameY * previewScale;
|
||
menu.style.left = `${Math.min(x, window.innerWidth - menu.offsetWidth - 8)}px`;
|
||
menu.style.top = `${Math.min(y, window.innerHeight - menu.offsetHeight - 8)}px`;
|
||
menu.querySelector('button')?.focus({ preventScroll: true });
|
||
}
|
||
function closeContextMenu() { $('#context-menu').hidden = true; }
|
||
document.addEventListener('mousedown', event => { if (event.button === 0 && !event.target.closest('#context-menu')) closeContextMenu(); });
|
||
window.addEventListener('blur', closeContextMenu);
|
||
window.addEventListener('resize', closeContextMenu);
|
||
$('.canvas-scroll').addEventListener('scroll', closeContextMenu, { passive: true });
|
||
$('#context-menu').addEventListener('keydown', event => {
|
||
const items = [...event.currentTarget.querySelectorAll('button')];
|
||
const index = items.indexOf(document.activeElement);
|
||
if (event.key === 'ArrowDown') { event.preventDefault(); items[(index + 1) % items.length]?.focus(); }
|
||
if (event.key === 'ArrowUp') { event.preventDefault(); items[(index - 1 + items.length) % items.length]?.focus(); }
|
||
if (event.key === 'Escape') { event.stopPropagation(); closeContextMenu(); $('.canvas-scroll').focus({ preventScroll: true }); }
|
||
});
|
||
|
||
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
|
||
$('#delete').addEventListener('click', () => actions.delete(selected));
|
||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||
$('#outdent').addEventListener('click', () => {
|
||
// Derselbe Weg wie ein Zug im Baum, mit denselben Regeln: neben den Elternblock, sofern der
|
||
// Grosselternblock den Baustein nimmt.
|
||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); moveBlockTo(doc, selected, parent.parent?.id ?? null, parent.index + 1); }); changed();
|
||
});
|
||
// ---------------------------------------------------------------------------------------------
|
||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||
// ---------------------------------------------------------------------------------------------
|
||
overlay = createCanvasOverlay({
|
||
stage: $('#preview-stage'),
|
||
frame: $('#preview'),
|
||
getScale: () => previewScale,
|
||
getDocument: () => history.document,
|
||
getSelected: () => selected,
|
||
onAction: (action, id) => {
|
||
if (!id || busy || !history) return;
|
||
if (action === 'up') actions.move(id, -1);
|
||
else if (action === 'down') actions.move(id, 1);
|
||
else actions[action]?.(id);
|
||
},
|
||
onInsert: (id, where) => {
|
||
const item = locate(history.document.blocks, id);
|
||
if (!item) return;
|
||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||
},
|
||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||
// the whole row for the current device, one undo step per drag.
|
||
onResize: (rowId, value) => {
|
||
if (busy || !history) return;
|
||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||
changed();
|
||
},
|
||
onDrop: (payload, plan) => {
|
||
if (busy || !history) return;
|
||
if (payload.kind === 'add') {
|
||
const id = history.change(doc => payload.columns
|
||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||
if (id) {
|
||
selected = payload.type === 'accordion' ? locate(history.document.blocks, id)?.block.children?.[0]?.id ?? id : id;
|
||
if (['accordion', 'accordionitem'].includes(payload.type)) propertyPanel = 'content';
|
||
changed();
|
||
}
|
||
return;
|
||
}
|
||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||
if (moved) { selected = moved; changed(); }
|
||
},
|
||
});
|
||
|
||
// Elements are dragged out of the toolbox straight onto the page. The click route stays, so the
|
||
// toolbox remains usable with the keyboard alone.
|
||
$('#library').addEventListener('dragstart', event => {
|
||
const button = event.target.closest('[data-add]');
|
||
if (!button || button.disabled || busy || !history) { event.preventDefault(); return; }
|
||
const type = button.dataset.add;
|
||
event.dataTransfer.effectAllowed = 'copy';
|
||
event.dataTransfer.setData('text/plain', type);
|
||
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
|
||
// one does not exist until it lands.
|
||
overlay.startDrag({ kind: 'add', type, columns: button.dataset.row ?? null, block: { type } });
|
||
});
|
||
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
||
|
||
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||
$('#save').addEventListener('click', () => save());
|
||
$('#publish')?.addEventListener('click', () => $('#publish-dialog').showModal());
|
||
$('#cancel-publish').addEventListener('click', () => $('#publish-dialog').close());
|
||
$('#confirm-publish').addEventListener('click', () => { $('#publish-dialog').close(); save(true); });
|
||
$('#cancel-preset').addEventListener('click', () => $('#preset-dialog').close());
|
||
$('#confirm-preset').addEventListener('click', async () => {
|
||
const name = $('#preset-name').value.trim();
|
||
const error = $('#preset-error');
|
||
try {
|
||
const response = await fetch(adminBase + '/api/style-presets', {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': designSchema.csrfToken },
|
||
body: JSON.stringify({ name, style: pendingPreset }),
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error || 'Die Vorlage konnte nicht gespeichert werden.');
|
||
// Rebuild the panel so the new preset is immediately offered.
|
||
designSchema.presets = body.presets;
|
||
$('#preset-dialog').close();
|
||
renderProperties();
|
||
} catch (problem) {
|
||
error.textContent = problem.message;
|
||
error.hidden = false;
|
||
}
|
||
});
|
||
$('#reload').addEventListener('click', () => $('#reload-dialog').showModal());
|
||
$('#cancel-reload').addEventListener('click', () => $('#reload-dialog').close());
|
||
$('#confirm-reload').addEventListener('click', () => { $('#reload-dialog').close(); load(); });
|
||
$('#download').addEventListener('click', () => {
|
||
if (!history) return;
|
||
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
|
||
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
});
|
||
/**
|
||
* Switches the preview size and the device the design panel edits. Both move together on
|
||
* purpose: editing the mobile value while the preview shows desktop means changing something
|
||
* you cannot see.
|
||
*/
|
||
function setViewport(name) {
|
||
if (!devices[name]) return;
|
||
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.viewport === name)));
|
||
$('#preview').className = `viewport-${name}`;
|
||
device = name;
|
||
if (history) history.mergeKey = null;
|
||
resizePreview(); renderProperties(); controls();
|
||
}
|
||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||
/** Whether a key press belongs to a field rather than to the page. */
|
||
const inField = target => ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
|
||
// One handler for the editor window and for the preview document: a key pressed while the
|
||
// preview has the focus - after a click into it - must mean the same thing.
|
||
function onKey(event) {
|
||
if (!history || busy) return;
|
||
const key = event.key.toLowerCase();
|
||
if (event.ctrlKey || event.metaKey) {
|
||
if (key === 's') { event.preventDefault(); save(); }
|
||
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
||
if (inField(event.target)) return;
|
||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||
return;
|
||
}
|
||
if (event.key === 'Escape') {
|
||
if (!$('#context-menu').hidden) { closeContextMenu(); return; }
|
||
if (document.querySelector('dialog[open]') || inlineEdit) return;
|
||
if (pendingInsert) { setPendingInsert(null); if (selected) setView('element'); return; }
|
||
if (selected) { choose(null); $('.canvas-scroll').focus({ preventScroll: true }); }
|
||
return;
|
||
}
|
||
// Delete removes the selected element - but never while a field or the preview text is
|
||
// being typed in, and never something the sidebar buttons would refuse.
|
||
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
|
||
event.preventDefault(); actions.delete(selected);
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKey);
|
||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||
buildLibrary();
|
||
// The workspace is a fixed frame; a scroll position restored from an earlier visit would push
|
||
// the header out of view.
|
||
window.scrollTo(0, 0);
|
||
load();
|