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>
256 lines
12 KiB
JavaScript
256 lines
12 KiB
JavaScript
/**
|
||
* The design panel.
|
||
*
|
||
* Every control is generated from the style schema the server sends, so the editor offers exactly
|
||
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
|
||
* appear here without touching this file.
|
||
*/
|
||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
|
||
|
||
export const GROUPS = {
|
||
text: 'Typografie',
|
||
background: 'Hintergrund',
|
||
spacing: 'Abstand',
|
||
size: 'Größe',
|
||
border: 'Rahmen',
|
||
layout: 'Layout',
|
||
effect: 'Wirkung',
|
||
};
|
||
|
||
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch'];
|
||
|
||
/** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */
|
||
function splitLength(value) {
|
||
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? '');
|
||
if (match) return { number: match[1], unit: match[2] };
|
||
return { number: value === '0' ? '0' : '', unit: 'px' };
|
||
}
|
||
|
||
function element(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSelection, getHistory }) {
|
||
const change = mutate => { getHistory().change(mutate); onChange(); };
|
||
|
||
/** One labelled row with a reset button that appears as soon as this device has an own value. */
|
||
const row = (id, labelText, control, { own, inherited, onReset }) => {
|
||
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
|
||
const head = element('div', 'design-row-head');
|
||
const label = element('label', null, labelText);
|
||
label.htmlFor = id;
|
||
head.append(label);
|
||
if (own) {
|
||
const reset = element('button', 'text-button design-reset-one', '⟲');
|
||
reset.type = 'button';
|
||
reset.title = 'Auf geerbten Wert zurücksetzen';
|
||
reset.setAttribute('aria-label', `${labelText} zurücksetzen`);
|
||
reset.addEventListener('click', onReset);
|
||
head.append(reset);
|
||
}
|
||
wrap.append(head, control);
|
||
if (!own && inherited) wrap.append(element('p', 'design-inherited', inherited));
|
||
return wrap;
|
||
};
|
||
|
||
const colorControl = (id, value, apply) => {
|
||
const wrap = element('div', 'color-control');
|
||
const picker = document.createElement('input');
|
||
picker.type = 'color';
|
||
picker.id = id;
|
||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
||
const text = document.createElement('input');
|
||
text.type = 'text';
|
||
text.className = 'color-text';
|
||
text.value = value ?? '';
|
||
text.placeholder = '#1a2b3c';
|
||
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
|
||
const palette = document.createElement('select');
|
||
palette.className = 'color-palette';
|
||
palette.setAttribute('aria-label', 'Farbe aus der Palette');
|
||
palette.append(new Option('Palette …', ''));
|
||
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
|
||
palette.value = (value ?? '').startsWith('@') ? value : '';
|
||
|
||
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
|
||
text.addEventListener('change', () => apply(text.value.trim()));
|
||
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
|
||
wrap.append(picker, text, palette);
|
||
return wrap;
|
||
};
|
||
|
||
const lengthControl = (id, value, apply, placeholder) => {
|
||
const wrap = element('div', 'length-control');
|
||
const parts = splitLength(value);
|
||
const number = document.createElement('input');
|
||
number.type = 'number';
|
||
number.id = id;
|
||
number.step = 'any';
|
||
number.value = parts.number;
|
||
number.placeholder = placeholder ?? 'erbt';
|
||
const unit = document.createElement('select');
|
||
unit.setAttribute('aria-label', 'Einheit');
|
||
for (const name of UNITS) unit.append(new Option(name, name));
|
||
unit.value = parts.unit;
|
||
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
|
||
number.addEventListener('change', push);
|
||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||
wrap.append(number, unit);
|
||
return wrap;
|
||
};
|
||
|
||
const numberControl = (id, value, property, apply) => {
|
||
const wrap = element('div', 'number-control');
|
||
const slider = document.createElement('input');
|
||
slider.type = 'range';
|
||
slider.min = property.min; slider.max = property.max; slider.step = property.step;
|
||
slider.value = value ?? property.min;
|
||
slider.setAttribute('aria-label', `${property.label} Schieberegler`);
|
||
const exact = document.createElement('input');
|
||
exact.type = 'number';
|
||
exact.id = id;
|
||
exact.min = property.min; exact.max = property.max; exact.step = property.step;
|
||
exact.value = value ?? '';
|
||
exact.placeholder = 'erbt';
|
||
slider.addEventListener('input', () => { exact.value = slider.value; });
|
||
slider.addEventListener('change', () => apply(slider.value));
|
||
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
|
||
wrap.append(slider, exact);
|
||
return wrap;
|
||
};
|
||
|
||
const choiceControl = (id, value, property, apply, inheritedLabel) => {
|
||
const select = document.createElement('select');
|
||
select.id = id;
|
||
select.append(new Option(`Übernehmen: ${inheritedLabel}`, ''));
|
||
for (const [key, label] of Object.entries(property.values)) select.append(new Option(label, key));
|
||
select.value = value ?? '';
|
||
select.addEventListener('change', () => apply(select.value));
|
||
return select;
|
||
};
|
||
|
||
const fontControl = (id, value, apply, inheritedLabel) => {
|
||
const select = document.createElement('select');
|
||
select.id = id;
|
||
select.append(new Option(`Übernehmen: ${inheritedLabel}`, ''));
|
||
for (const font of schema.fonts) select.append(new Option(`${font.name}${font.family ? ` · ${font.family}` : ''}`, font.key));
|
||
select.value = value ?? '';
|
||
select.addEventListener('change', () => apply(select.value));
|
||
return select;
|
||
};
|
||
|
||
/** Four inputs for padding, margin, border width and corners. */
|
||
const sideControl = (key, block, deviceName, property) => {
|
||
const grid = element('div', 'side-control');
|
||
const parts = property.corners ? schema.corners : schema.sides;
|
||
for (const [side, label] of Object.entries(parts)) {
|
||
const cell = element('div', 'side-cell');
|
||
const id = `style-${key}-${side}`;
|
||
const caption = element('label', null, label);
|
||
caption.htmlFor = id;
|
||
const current = effectiveStyle(block, deviceName, key, side);
|
||
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
|
||
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
|
||
}, current.value ? String(current.value) : 'erbt');
|
||
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
|
||
cell.append(caption, control);
|
||
grid.append(cell);
|
||
}
|
||
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
|
||
all.type = 'button';
|
||
all.addEventListener('click', () => {
|
||
const first = grid.querySelector('input[type=number]');
|
||
const unit = grid.querySelector('select');
|
||
if (first?.value === '') return;
|
||
change(doc => {
|
||
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
|
||
});
|
||
});
|
||
const wrap = element('div', 'side-wrap');
|
||
wrap.append(grid, all);
|
||
return wrap;
|
||
};
|
||
|
||
/** Renders the whole panel for one block into the given container. */
|
||
return function render(panel) {
|
||
const block = getBlock();
|
||
const deviceName = getDevice();
|
||
if (!block) return;
|
||
|
||
const scope = element('p', 'design-scope');
|
||
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
|
||
panel.append(scope);
|
||
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
|
||
|
||
for (const [group, groupLabel] of Object.entries(GROUPS)) {
|
||
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
|
||
if (properties.length === 0) continue;
|
||
const details = element('details', 'design-group');
|
||
if (group === 'text' || group === 'spacing') details.open = true;
|
||
details.append(element('summary', null, groupLabel));
|
||
const body = element('div', 'design-group-body');
|
||
|
||
for (const [key, property] of properties) {
|
||
const id = `style-${key}`;
|
||
const current = effectiveStyle(block, deviceName, key);
|
||
const own = block.style?.[deviceName]?.[key] !== undefined;
|
||
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
|
||
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
|
||
const inheritedText = current.value !== undefined && !own
|
||
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
|
||
: 'Theme-Standard';
|
||
|
||
let control;
|
||
if (property.sides || property.corners) {
|
||
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
|
||
continue;
|
||
}
|
||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
|
||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||
else continue; // media properties are set through the media library
|
||
|
||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
|
||
|
||
if (schema.hoverable.includes(key)) {
|
||
const hoverId = `${id}-hover`;
|
||
const hoverValue = hoverStyle(block, deviceName, key);
|
||
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
|
||
const hoverControl = property.type === 'color'
|
||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||
: property.type === 'number'
|
||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||
body.append(row(hoverId, `${property.label} beim Überfahren`, hoverControl, {
|
||
own: hoverValue !== undefined,
|
||
inherited: 'Kein eigener Hover-Wert',
|
||
onReset: () => hoverApply(''),
|
||
}));
|
||
}
|
||
}
|
||
details.append(body);
|
||
panel.append(details);
|
||
}
|
||
|
||
const actions = element('div', 'design-actions');
|
||
const resetDevice = element('button', 'secondary', `${devices[deviceName]} zurücksetzen`);
|
||
resetDevice.type = 'button';
|
||
resetDevice.addEventListener('click', () => change(doc => resetStyleDevice(doc, getSelection(), deviceName)));
|
||
const resetAll = element('button', 'secondary danger-text', 'Gesamte Gestaltung entfernen');
|
||
resetAll.type = 'button';
|
||
resetAll.addEventListener('click', () => change(doc => clearStyle(doc, getSelection())));
|
||
actions.append(resetDevice, resetAll);
|
||
panel.append(actions);
|
||
};
|
||
|
||
function describe(property, value) {
|
||
if (property.type === 'choice') return property.values[value] ?? value;
|
||
return String(value);
|
||
}
|
||
}
|