Files
KantSites/public/assets/editor.js
T
kantarciandClaude Opus 5 ba56add678 Browsertests wiederhergestellt; drei echte Fehler gefunden
Der Playwright-Editortest war durch den Editorumbau kaputt: er
steuerte die abgeschafften Auswahllisten. Umgeschrieben auf die
freien Bedienelemente - Einheitenfelder, Vererbung über drei Geräte,
einzelnes Zurücksetzen, Palettenfarbe, Abstand je Seite, Hover-Regel,
freie Rasterspalten und die weiter vorhandenen Bewegungseffekte.

Dabei kamen drei Fehler heraus, die ohne Browserlauf unsichtbar sind:

1. Die frei gestalteten Werte wirkten in der Editorvorschau gar
   nicht. Ein srcdoc-Iframe erbt die Richtlinie des einbettenden
   Dokuments, und die Verwaltung verbietet Inline-Styles - das
   erzeugte style-Element wurde stillschweigend verworfen.
   Ausgeliefert wurde es korrekt, nur die Vorschau log. Jetzt als
   inertes template plus adoptedStyleSheets.
2. Die Einrückung der Unterseiten in der Seitenliste hat nie
   funktioniert; sie lief über ein style-Attribut, das dieselbe
   Richtlinie verwirft. Jetzt über Klassen.
3. Das Theme setzte alle Links auf die Akzentfarbe und überschrieb
   die kontextabhängigen Farben: 3,08:1 auf dunklem Grund. Die Regel
   gilt jetzt nur für Links in Textblöcken, die voreingestellte
   Linkfarbe liegt auf allen hellen Tönen über 4,5:1.

Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, vollständiger Editortest mit axe (null
Verstöße) und Lighthouse 13.4.1 mit je drei Läufen mobil und Desktop,
alle fünf Kategorien 100.

Offen: npm run test:browser schlägt wegen eines Inhalts fehl -
/grundlagen trägt einen im Editor gesetzten Textblock mit 2,75:1.
Nicht verändert, weil es eine Nutzerentscheidung ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:16:08 +02:00

637 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.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 root = document.querySelector('#editor');
const $ = selector => document.querySelector(selector);
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
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;
const collapsed = new Set();
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' : 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;
}
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#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,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
$('[data-panel="design"]').disabled ||= !current;
$('#select-page').disabled = !ready || busy;
$('#open-media-library').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;
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' }); }
}
function renderProperties() {
const block = locate(history.document.blocks, selected)?.block;
if (!block) propertyPanel = 'content';
$('#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 === '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; if (key === 'decorative' && next) current.props.alt = ''; }
else current[key] = next;
if (key === 'caption') upgradeDocument(doc, 3);
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 (['heading', 'text', '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 === '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 === '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.' });
}
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'image') {
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: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
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 });
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
}
if (block.type === 'gallery') {
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: 'Normal', narrow: 'Schmal' } });
}
}
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', zoom: 'Bild vergrößern' }],
['entrance', 'Einblendung beim Laden', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten einblenden' }],
]) {
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 = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder.'; 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;
element.setAttribute('contenteditable', '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 = 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 ? 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 width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
const available = Math.max(100, container.clientWidth - padding);
const scale = Math.min(1, available / width);
const height = Math.max(550, (container.clientHeight - padding) / 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`;
}
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;
frame.onload = () => {
if (sequence !== previewSequence) return;
const doc = frame.contentDocument;
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; 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();
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());
frame.contentWindow.scrollTo(0, top); highlight();
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
};
frame.srcdoc = html;
} 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;
renderTree(); renderProperties(); controls(); schedulePreview();
}
async function loadDesignSchema() {
try {
const response = await fetch('/admin/api/design', { credentials: 'same-origin' });
if (!response.ok) return;
designSchema = await response.json();
designPanel = createDesignPanel({
schema: designSchema,
onChange: () => { controls(); renderTree(); schedulePreview(); 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,
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('');
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(); }
}
$('#library').addEventListener('click', event => {
const type = event.target.closest('[data-add]')?.dataset.add;
if (!type || busy || !history) return;
selected = history.change(doc => insertBlock(doc, type, selected)); 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) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
});
$('#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();
});
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
$('#delete').addEventListener('click', () => {
selected = history.change(doc => { const item = locate(doc.blocks, selected); 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();
});
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); 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]]; }); changed();
});
$('#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();
});
$('#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('/admin/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);
});
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
$('#preview').className = `viewport-${button.dataset.viewport}`;
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
}));
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
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 = ''; } });
load();