1193 lines
73 KiB
JavaScript
1193 lines
73 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 } from './media.js';
|
||
import { createDesignPanel } from './design-panel.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';
|
||
|
||
// 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'];
|
||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||
|
||
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 = () => {};
|
||
|
||
function playPreview() {
|
||
stopPlayback(); stopPlayback = () => {};
|
||
const button = $('#preview-motion');
|
||
button.setAttribute('aria-pressed', String(previewPlayback));
|
||
button.textContent = previewPlayback ? 'Bewegung stoppen' : 'Bewegung testen';
|
||
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']) $(`#${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);
|
||
$('#outdent').disabled ||= !current.parent;
|
||
}
|
||
updateLibrary();
|
||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||
$('[data-panel="design"]').disabled ||= !current;
|
||
$('#select-page').disabled = !ready || busy;
|
||
$('#add-root-section').disabled = !ready || busy;
|
||
$('#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 || ''}`.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));
|
||
button.setAttribute('aria-label', `${blockLabel(block)}: ${block.props?.text || block.props?.alt || blockLabel(block)}`);
|
||
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 = block.props?.text || block.props?.alt || blockLabel(block);
|
||
button.append(icon, text); item.append(button);
|
||
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;
|
||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||
}
|
||
|
||
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'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
||
overlay?.sync();
|
||
}
|
||
|
||
function renderProperties() {
|
||
const block = locate(history.document.blocks, selected)?.block;
|
||
if (!block) propertyPanel = 'content';
|
||
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
||
if (context !== renderedProperties) $('.right-panel').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)));
|
||
$('#breadcrumbs').replaceChildren();
|
||
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
|
||
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? blockLabel(item) : 'Seite';
|
||
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
|
||
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||
}
|
||
if (block && isPluginBlock(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); return; }
|
||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||
const field = (label, key, value, options = {}) => {
|
||
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;
|
||
input.addEventListener('input', () => {
|
||
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
|
||
history.change(doc => {
|
||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||
if (!current) return;
|
||
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 (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||
}, `${selected || 'page'}:${key}`);
|
||
if (key === 'decorative') renderProperties();
|
||
renderTree(); controls(); schedulePreview();
|
||
});
|
||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||
if (options.checkbox) panel.append(labelEl); else panel.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); panel.append(help); }
|
||
};
|
||
if (!block) {
|
||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||
return;
|
||
}
|
||
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' });
|
||
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 (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.' });
|
||
}
|
||
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.' });
|
||
}
|
||
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.' });
|
||
}
|
||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||
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.' });
|
||
}
|
||
if (block.type === 'progress') {
|
||
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||
field('Wert (%)', 'value', block.props.value, { number: true, 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, 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 === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort 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 === 'accordionitem') {
|
||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||
}
|
||
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. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||
}
|
||
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 || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||
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.' });
|
||
if (block.type === 'image') {
|
||
renderImageWidth(block, panel);
|
||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
||
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||
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: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
|
||
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 (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;
|
||
scriptInput.setAttribute('aria-describedby', 'own-script-locked');
|
||
}
|
||
}
|
||
|
||
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 (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 650 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);
|
||
}
|
||
|
||
function chooseMedia(multiple) {
|
||
const target = selected;
|
||
openMedia({ multiple, onChoose: items => {
|
||
if (!items.length || busy) return;
|
||
selected = history.change(doc => insertMedia(doc, target, items)); 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 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-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 setPendingInsert({ parentId: id, index: 0 });
|
||
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); }
|
||
});
|
||
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 = '';
|
||
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;
|
||
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,
|
||
});
|
||
} 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);
|
||
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);
|
||
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
|
||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||
: insertBlock(doc, type, selected));
|
||
changed();
|
||
}
|
||
|
||
/** 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) {
|
||
if ($('#toolbox').hidden) $('#toggle-library').click();
|
||
$('#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 });
|
||
$('#library-search').scrollIntoView({ block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
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.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
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 ? `${labels[type]}, ${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} ${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);
|
||
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; }
|
||
const target = pendingInsert
|
||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||
: insertionTarget(history.document, selected, type);
|
||
button.disabled = !target;
|
||
if (!target) { button.title = 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));
|
||
$('#add-root-section').addEventListener('click', () => {
|
||
if (busy || !history) return;
|
||
stopInlineEdit();
|
||
setPendingInsert(null);
|
||
selected = history.change(doc => insertBlock(doc, 'section', selected));
|
||
propertyPanel = 'content'; changed();
|
||
});
|
||
$('#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 id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
|
||
});
|
||
$('#tree-search').addEventListener('input', renderTree);
|
||
$('#toggle-library').addEventListener('click', () => {
|
||
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
|
||
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
|
||
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
|
||
$('#toggle-library').textContent = hidden ? '+' : '−';
|
||
});
|
||
$('#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),
|
||
};
|
||
|
||
$('#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', () => {
|
||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); 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) });
|
||
},
|
||
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 = id; 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());
|
||
|
||
$('#select-page').addEventListener('click', () => choose(null));
|
||
$('#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'));
|
||
// Panel sizes are workspace preferences, independent of document history and publication.
|
||
const panelSizes = { left: 280, right: 320 };
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
|
||
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
|
||
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
|
||
const panelMinimum = { left: 240, right: 280 };
|
||
function layoutPanels() {
|
||
let left = Math.max(240, Math.min(560, panelSizes.left));
|
||
let right = Math.max(280, Math.min(560, panelSizes.right));
|
||
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
|
||
const excess = Math.max(0, left + right - budget);
|
||
const spare = left + right - 520;
|
||
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
|
||
for (const [side, size] of Object.entries({ left, right })) {
|
||
root.style.setProperty(`--panel-${side}`, `${size}px`);
|
||
const handle = $(`[data-resize="${side}"]`);
|
||
handle.setAttribute('aria-valuenow', String(Math.round(size)));
|
||
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
|
||
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
|
||
}
|
||
}
|
||
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
|
||
for (const handle of document.querySelectorAll('[data-resize]')) {
|
||
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
|
||
let drag = null;
|
||
const setSize = value => {
|
||
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
|
||
layoutPanels();
|
||
};
|
||
handle.addEventListener('pointerdown', event => {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault(); handle.focus();
|
||
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
|
||
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
|
||
});
|
||
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
|
||
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
|
||
handle.addEventListener('pointerup', stop);
|
||
handle.addEventListener('pointercancel', stop);
|
||
handle.addEventListener('lostpointercapture', stop);
|
||
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
|
||
handle.addEventListener('keydown', event => {
|
||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const current = Number(handle.getAttribute('aria-valuenow'));
|
||
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
|
||
persistPanels();
|
||
});
|
||
}
|
||
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
|
||
layoutPanels();
|
||
window.addEventListener('keydown', event => {
|
||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||
const key = event.key.toLowerCase();
|
||
if (key === 's') { event.preventDefault(); save(); }
|
||
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
|
||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||
});
|
||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||
buildLibrary();
|
||
load();
|