Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
15 KiB
JavaScript
314 lines
15 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' };
|
|
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: '⟩' };
|
|
// 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 === '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);
|
|
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].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10].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].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;
|
|
}
|