Dokumentschema v6 ergänzt iconcard, stat, quote, logos, cta, contact, divider, spacer, accordion und accordionitem - die Abschnittsarten, aus denen die Startseite von kant-it.de besteht. Symbole liegen als Inline-SVG aus einer festen Liste im Template: kein Symbolschriftsatz, keine externe Anfrage. Kontaktdaten werden zu tel:- und mailto:-Verweisen mit bereinigter Nummer. Das Akkordeon nutzt details/summary und braucht damit kein öffentliches JavaScript. Tabs sind bewusst nicht umgesetzt: ohne JavaScript gibt es dafür keine zugängliche Lösung, und eine CSS-Variante über Radiobuttons wird als Auswahlfeld statt als Tabliste angekündigt. Begründung in ADR 015. Zwei Fehler von den Tests gefunden und behoben: die Regel "ein Akkordeon-Eintrag gehört in ein Akkordeon" griff nicht auf oberster Dokumentebene, und ein JavaScript-Test suchte im vorherigen Dokumentstand, weil history.document vor history.change ausgewertet wurde. Geprüft: 107 PHP-Tests / 380 Assertions unter PHP 8.2.12 und 8.4.22, 13 JavaScript-Modelltests, Lint/Format/Container. Keine Browser- oder Lighthouse-Läufe; für die neuen Blöcke liegt damit keine visuelle Kontrolle und keine Barrierefreiheitsprüfung vor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
452 lines
30 KiB
JavaScript
452 lines
30 KiB
JavaScript
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||
import { openMedia } from './media.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;
|
||
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 (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 === '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) {
|
||
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);
|
||
const heading = document.createElement('h3'); heading.className = 'preset-heading'; heading.textContent = 'Effekte · alle Geräte'; panel.append(heading);
|
||
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();
|
||
} });
|
||
}
|
||
|
||
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() {
|
||
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);
|
||
doc.addEventListener('click', event => {
|
||
event.preventDefault();
|
||
const element = event.target.closest('[id]');
|
||
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||
});
|
||
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 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();
|
||
}
|
||
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); });
|
||
$('#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();
|