Der Lesevalidator wies bisher jeden unbekannten Blocktyp ab, womit der Fallback im Renderer unerreichbar war: eine Seite mit Bausteinen aus einer deaktivierten Erweiterung wäre nicht mehr ladbar gewesen. Ab Schema v4 gilt ein Typ mit Namensraum (acme.slider) als Baustein einer Erweiterung und bleibt erhalten. Der Kern prüft weiterhin ID, Tiefe, Blockanzahl und Dokumentgröße, aber bewusst nicht die Props - er kennt das Schema der Erweiterung nicht. Damit ungeprüfte Werte nie ausgegeben werden, rendert der Kern aus einem solchen Block nichts: öffentlich nur ein HTML-Kommentar, in der Editorvorschau ein Platzhalter. Im Editor bleiben die Blöcke auswählbar, verschiebbar, duplizierbar und löschbar, aber nicht bearbeitbar, und es lässt sich nichts in sie einfügen. Testumgebung: Twig lädt Vorlagen jetzt automatisch neu. Ohne Debug konnte eine kompilierte Vorlage im Cache den Ausgang eines Testlaufs bestimmen. Geprüft: 64 PHP-Tests / 229 Assertions unter PHP 8.2.12 und 8.4.22, 11 JavaScript-Modelltests, Lint/Format/Container. Keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
27 KiB
JavaScript
412 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 === '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();
|