import { EditorHistory, labels, icons, 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 => `${labels[block.type]} ${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', `${labels[block.type]} ${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', `${labels[block.type]}: ${block.props?.text || block.props?.alt || labels[block.type]}`); const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = icons[block.type]; const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || labels[block.type]; 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 ? labels[block.type] : '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 ? labels[item.type] : 'Seite'; button.setAttribute('aria-current', item.id === selected ? 'location' : 'false'); button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button); } 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();