Migration 20260912001000 legt die Tabelle partial an: Blockdokumente, die keine Seiten sind. Kopf- und Fußbereich je einmal, wiederverwendbare Sektionen beliebig viele - im selben Editor gestaltbar wie eine Seite, weil der Editor seine Schnittstelle aus einem Datenattribut nimmt statt sie fest zu kennen. Im Template steht keine Marke und keine Navigation mehr. Ein neuer Block menu zeigt einen Menüplatz; die Einträge bleiben in der Menüverwaltung. Eine Seite braucht genau eine Hauptüberschrift, ein geteilter Bereich keine - sonst hätte jede Seite, die ihn trägt, zwei. Geteilte Bereiche haben keinen Entwurf und keine Veröffentlichung: sie sind überall sichtbar, gespeichert heißt live. Verknüpft statt kopiert: partialref speichert nur die Adresse, die Blöcke bleiben im Bereich, und eine Änderung erreicht jede Seite, die sie einbindet. Zyklen abgewiesen, Verschachtelung auf drei Ebenen begrenzt, eine entfernte Sektion hinterlässt keine Lücke und der Verweis bleibt erhalten. Die Abschnittsvorlagen kopieren weiterhin. Ein echter Fehler kam dabei heraus, der jede dunkle Sektion mit Spalten betraf: .block-column>.block-text:first-child setzt eine Farbe für helle Hintergründe und gewinnt auf Spezifität gegen .tone-ink - gemessen 1,87:1. Geprüft: 165 PHP-Tests / 660 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Editortest mit axe ohne Verstöße, öffentliche Browserprüfung, Migration nach Backup angewendet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
317 lines
16 KiB
JavaScript
317 lines
16 KiB
JavaScript
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion' };
|
|
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉' };
|
|
// 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', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'].includes(block?.type);
|
|
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
|
// Containers that accept only one kind of child, mirrored by the server side validator.
|
|
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
|
|
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
|
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
|
|
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
|
|
&& (block.type !== 'tabitem' || parent.type === 'tabs');
|
|
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 === 'accordion') block.children = [createBlock('accordionitem')];
|
|
// A single tab is not a tab group, and the script needs at least two to enhance anything.
|
|
if (type === 'tabs') block.children = [createBlock('tabitem'), createBlock('tabitem')];
|
|
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 === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
|
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
|
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
|
if (type === 'cta') block.props = { title: 'Bereit für den nächsten Schritt?', text: '', buttonText: 'Jetzt anfragen', href: '/' };
|
|
if (type === 'contact') block.props = { phone: '', email: '', address: '' };
|
|
if (type === 'spacer') block.props = { size: 'medium' };
|
|
if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false };
|
|
if (type === 'tabitem') block.props = { title: 'Neuer Tab' };
|
|
if (type === 'form') block.props = { form: '' };
|
|
if (type === 'menu') block.props = { slot: 'header' };
|
|
if (type === 'partialref') block.props = { partial: '' };
|
|
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);
|
|
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
|
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
|
if (type === 'form') upgradeDocument(document, 8);
|
|
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
|
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
|
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, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11].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, 6, 7, 8, 9, 10, 11].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; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Free block styles (document schema v10).
|
|
//
|
|
// A style is { device: { property: value, hover: { property: value } } }. Desktop is the base,
|
|
// tablet and mobile override it. Everything mirrors StyleSchema on the server: the editor can only
|
|
// offer what the server accepts, and a value the server would reject never reaches a document.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/** The value that actually applies on a device, plus the device it was inherited from. */
|
|
export function effectiveStyle(block, device, key, side = null) {
|
|
let value, source;
|
|
for (const name of Object.keys(devices)) {
|
|
const stored = block?.style?.[name]?.[key];
|
|
const candidate = side === null ? stored : stored?.[side];
|
|
if (candidate !== undefined) { value = candidate; source = name; }
|
|
if (name === device) break;
|
|
}
|
|
return { value, source };
|
|
}
|
|
|
|
export function hoverStyle(block, device, key) {
|
|
return block?.style?.[device]?.hover?.[key];
|
|
}
|
|
|
|
/** Removes empty objects so an untouched block never carries a style skeleton. */
|
|
function prune(block) {
|
|
for (const device of Object.keys(block.style ?? {})) {
|
|
const values = block.style[device];
|
|
for (const key of Object.keys(values)) {
|
|
if (values[key] && typeof values[key] === 'object' && Object.keys(values[key]).length === 0) delete values[key];
|
|
}
|
|
if (Object.keys(values).length === 0) delete block.style[device];
|
|
}
|
|
if (block.style && Object.keys(block.style).length === 0) delete block.style;
|
|
}
|
|
|
|
/**
|
|
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
|
|
* device and finally to the theme. Called inside a history transaction like every other change.
|
|
*/
|
|
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
|
|
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
|
const block = locate(document.blocks, id)?.block;
|
|
if (!block) return;
|
|
const empty = value === '' || value === null || value === undefined;
|
|
if (!empty) upgradeDocument(document, 10);
|
|
|
|
block.style ??= {};
|
|
block.style[device] ??= {};
|
|
let target = block.style[device];
|
|
if (hover) { target.hover ??= {}; target = target.hover; }
|
|
|
|
if (side !== null) {
|
|
if (empty) { if (target[key]) delete target[key][side]; }
|
|
else { target[key] ??= {}; target[key][side] = value; }
|
|
} else if (empty) {
|
|
delete target[key];
|
|
} else {
|
|
target[key] = value;
|
|
}
|
|
prune(block);
|
|
}
|
|
|
|
/** Drops every style of one device, so it inherits again. */
|
|
export function resetStyleDevice(document, id, device) {
|
|
const block = locate(document.blocks, id)?.block;
|
|
if (!block?.style) return;
|
|
delete block.style[device];
|
|
prune(block);
|
|
}
|
|
|
|
/** Drops the whole styling of a block. */
|
|
export function clearStyle(document, id) {
|
|
const block = locate(document.blocks, id)?.block;
|
|
if (!block) return;
|
|
delete block.style;
|
|
}
|
|
|
|
/** Copies the styling of one block onto another, with fresh objects. */
|
|
export function copyStyle(document, fromId, toId) {
|
|
const source = locate(document.blocks, fromId)?.block;
|
|
const target = locate(document.blocks, toId)?.block;
|
|
if (!source || !target) return false;
|
|
if (!source.style) { delete target.style; return true; }
|
|
upgradeDocument(document, 10);
|
|
target.style = structuredClone(source.style);
|
|
return true;
|
|
}
|