Files
KantSites/public/assets/editor-model.js
T
kantarciandClaude Opus 5 ebd5883593 Beiträge mit Archiven, Beitragsliste und Feed (Arbeitspaket B)
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>
2026-09-12 11:25:09 +02:00

204 lines
9.7 KiB
JavaScript

export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
export const blockIcon = block => icons[block?.type] ?? '❔';
export const isContainer = block => ['section', 'row', 'column', 'gallery'].includes(block?.type);
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) && (parent.type !== 'gallery' || block.type === 'image');
export const clone = value => structuredClone(value);
export function locate(blocks, id, parent = null) {
for (let index = 0; index < blocks.length; index++) {
const block = blocks[index];
if (block.id === id) return { block, siblings: blocks, index, parent };
const child = locate(block.children || [], id, block);
if (child) return child;
}
return null;
}
export function createBlock(type) {
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
if (isContainer(block)) block.children = [];
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
return block;
}
export function insertBlock(document, type, selected) {
const block = createBlock(type);
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
let target = locate(document.blocks, selected);
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
if (!target) document.blocks.push(block);
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
else target.siblings.splice(target.index + 1, 0, block);
return block.id;
}
export function duplicateBlock(document, selected) {
const target = locate(document.blocks, selected);
if (!target) return null;
const copy = clone(target.block);
const rekey = block => {
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
(block.children || []).forEach(rekey);
};
rekey(copy);
target.siblings.splice(target.index + 1, 0, copy);
return copy.id;
}
export function moveBlock(document, selected, targetId, inside = false) {
const source = locate(document.blocks, selected);
const target = locate(document.blocks, targetId);
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
if (inside && !isContainer(target.block)) return false;
if (inside && !canContain(target.block, source.block)) return false;
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
source.siblings.splice(source.index, 1);
const destination = locate(document.blocks, targetId);
if (inside) (destination.block.children ??= []).push(source.block);
else destination.siblings.splice(destination.index, 0, source.block);
return true;
}
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3, 4, 5].includes(document.schemaVersion) || ![2, 3, 4, 5].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
export function effectiveDesign(block, device, key) {
let value, source;
for (const name of Object.keys(devices)) {
if (block.layout?.design?.[name]?.[key] !== undefined) {
value = block.layout.design[name][key]; source = name;
}
if (name === device) break;
}
return { value, source };
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3, 4, 5].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
if (value === '') {
const design = block.layout?.design;
if (!design?.[device] || !Object.hasOwn(design[device], key)) return;
delete design[device][key];
if (!Object.keys(design[device]).length) delete design[device];
if (!Object.keys(design).length) delete block.layout.design;
} else {
upgradeDocument(document);
((block.layout ??= {}).design ??= {})[device] ??= {};
block.layout.design[device][key] = value;
}
}
export function blockPath(blocks, id) {
const found = locate(blocks, id);
return found ? [...(found.parent ? blockPath(blocks, found.parent.id) : []), found.block] : [];
}
export function setEffect(document, id, key, value) {
const block = locate(document.blocks, id)?.block;
if (!block) return;
upgradeDocument(document, 3);
((block.layout ??= {}).effects ??= {})[key] = value;
}
export function mediaProps(media) {
const largest = media.variants.at(-1);
return { mediaId: media.id, src: largest.src, width: largest.width, height: largest.height, alt: media.decorative ? '' : media.alt, decorative: Boolean(media.decorative), caption: '', sources: media.variants.map(({ src, width }) => ({ src, width })) };
}
export function insertMedia(document, selected, items) {
const target = locate(document.blocks, selected)?.block;
upgradeDocument(document, 3);
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
let parent = target;
if (target?.type !== 'gallery') {
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
}
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
return parent.id;
}
export function insertPreset(document, name, selected) {
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
const section = createBlock('section');
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
const paragraph = text => { const block = createBlock('text'); block.props.text = text; return block; };
section.layout = { tone: name === 'cta' ? 'ink' : 'paper', width: name === 'columns' ? 'normal' : 'narrow' };
section.children = [heading(name === 'cta' ? 'Lass uns gemeinsam starten.' : name === 'columns' ? 'Was wir für dich tun.' : 'Raum für deine nächste Idee.'), paragraph('Erzähle hier, was dein Angebot besonders macht und welchen Nutzen deine Besucher davon haben.')];
if (name === 'columns') {
const row = createBlock('row');
row.layout = { design: { desktop: { columns: 'two', gap: 'medium' }, mobile: { columns: 'one' } } };
row.children = ['Persönlich beraten', 'Gemeinsam umsetzen'].map(text => {
const column = createBlock('column');
const title = heading(text); title.props.level = 3;
column.children = [title, paragraph('Beschreibe dein Angebot in wenigen verständlichen Sätzen.')];
return column;
});
section.children.push(row);
upgradeDocument(document);
} else section.children.push(createBlock('button'));
const top = blockPath(document.blocks, selected)[0];
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
document.blocks.splice(index, 0, section);
return section.id;
}
export class EditorHistory {
constructor(document) { this.reset(document); }
reset(document) {
this.document = clone(document);
this.past = [];
this.future = [];
this.mergeKey = null;
this.saved = JSON.stringify(document);
}
change(mutator, mergeKey = null) {
const next = clone(this.document);
const result = mutator(next);
if (JSON.stringify(next) === JSON.stringify(this.document)) return result;
if (!mergeKey || this.mergeKey !== mergeKey) this.past.push(this.document);
if (this.past.length > 100) this.past.shift();
this.document = next;
this.future = [];
this.mergeKey = mergeKey;
return result;
}
undo() {
if (!this.past.length) return;
this.future.push(this.document);
this.document = this.past.pop();
this.mergeKey = null;
}
redo() {
if (!this.future.length) return;
this.past.push(this.document);
this.document = this.future.pop();
this.mergeKey = null;
}
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
get dirty() { return JSON.stringify(this.document) !== this.saved; }
}