Migration 20260912000600 ergänzt page.excerpt, author,
featured_media_id und published_at sowie die Tabellen term und
page_term. Beitragsmetadaten beschreiben die Seite, nicht ihren
Inhalt, und liegen deshalb als Spalten statt im Blockdokument: das
Archiv sortiert und paginiert damit in SQL, statt für jede
Auflistung alle Dokumente zu dekodieren.
Kategorien und Schlagwörter teilen sich eine Tabelle mit einem
kind-Feld. Beim Löschen einer Seite verschwinden ihre Zuordnungen,
die Begriffe bleiben als wiederverwendbare Etiketten; ein leer
gewordenes Archiv antwortet weiter mit 200 und leerer Liste.
Der Block postlist (Dokumentschema v5) speichert nur Anzahl und
Filter. Die Beiträge stellt der Controller beim Ausliefern zusammen,
damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag
zeigen kann. Archive unter /beitraege, /kategorie/{slug} und
/schlagwort/{slug}, Feed unter /feed.xml; alle vier Segmente sind
für Wurzelseiten reserviert.
Geprüft: 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22,
12 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
417 lines
27 KiB
JavaScript
417 lines
27 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';
|
||
|
||
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 === '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();
|