Der Playwright-Editortest war durch den Editorumbau kaputt: er steuerte die abgeschafften Auswahllisten. Umgeschrieben auf die freien Bedienelemente - Einheitenfelder, Vererbung über drei Geräte, einzelnes Zurücksetzen, Palettenfarbe, Abstand je Seite, Hover-Regel, freie Rasterspalten und die weiter vorhandenen Bewegungseffekte. Dabei kamen drei Fehler heraus, die ohne Browserlauf unsichtbar sind: 1. Die frei gestalteten Werte wirkten in der Editorvorschau gar nicht. Ein srcdoc-Iframe erbt die Richtlinie des einbettenden Dokuments, und die Verwaltung verbietet Inline-Styles - das erzeugte style-Element wurde stillschweigend verworfen. Ausgeliefert wurde es korrekt, nur die Vorschau log. Jetzt als inertes template plus adoptedStyleSheets. 2. Die Einrückung der Unterseiten in der Seitenliste hat nie funktioniert; sie lief über ein style-Attribut, das dieselbe Richtlinie verwirft. Jetzt über Klassen. 3. Das Theme setzte alle Links auf die Akzentfarbe und überschrieb die kontextabhängigen Farben: 3,08:1 auf dunklem Grund. Die Regel gilt jetzt nur für Links in Textblöcken, die voreingestellte Linkfarbe liegt auf allen hellen Tönen über 4,5:1. Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe (null Verstöße) und Lighthouse 13.4.1 mit je drei Läufen mobil und Desktop, alle fünf Kategorien 100. Offen: npm run test:browser schlägt wegen eines Inhalts fehl - /grundlagen trägt einen im Editor gesetzten Textblock mit 2,75:1. Nicht verändert, weil es eine Nutzerentscheidung ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
375 lines
18 KiB
JavaScript
375 lines
18 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';
|
||
import { openMedia } from './media.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' };
|
||
}
|
||
|
||
/** Relative luminance per WCAG 2.1, from an #rrggbb value. */
|
||
function luminance(hex) {
|
||
const channel = index => {
|
||
const value = parseInt(hex.slice(1 + index * 2, 3 + index * 2), 16) / 255;
|
||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
|
||
}
|
||
|
||
export function contrastRatio(foreground, background) {
|
||
const light = Math.max(luminance(foreground), luminance(background));
|
||
const dark = Math.min(luminance(foreground), luminance(background));
|
||
return (light + 0.05) / (dark + 0.05);
|
||
}
|
||
|
||
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, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
|
||
const change = mutate => { getHistory().change(mutate); onChange(); };
|
||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||
let clipboard = null;
|
||
|
||
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
|
||
const applyStyle = (doc, id, style) => {
|
||
for (const device of Object.keys(devices)) resetStyleDevice(doc, id, device);
|
||
if (!style) return;
|
||
for (const [device, values] of Object.entries(style)) {
|
||
for (const [key, value] of Object.entries(values)) {
|
||
if (key === 'hover') {
|
||
for (const [hoverKey, hoverValue] of Object.entries(value)) setStyle(doc, id, device, hoverKey, hoverValue, { hover: true });
|
||
} else if (value && typeof value === 'object') {
|
||
for (const [side, single] of Object.entries(value)) setStyle(doc, id, device, key, single, { side });
|
||
} else {
|
||
setStyle(doc, id, device, key, value);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
/** 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;
|
||
};
|
||
|
||
/** Resolves "@key" to the palette value so a contrast can actually be computed. */
|
||
const resolveColor = value => {
|
||
if (typeof value !== 'string') return null;
|
||
if (/^#[0-9a-fA-F]{6}$/.test(value)) return value.toLowerCase();
|
||
if (value.startsWith('@')) {
|
||
const entry = schema.colors.find(color => color.key === value.slice(1));
|
||
return entry && /^#[0-9a-fA-F]{6}$/.test(entry.value) ? entry.value.toLowerCase() : null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/** A warning, never a prohibition: the plan asks for a contrast check, not for tutelage. */
|
||
const contrastNotice = (block, deviceName) => {
|
||
const ownForeground = resolveColor(effectiveStyle(block, deviceName, 'color').value);
|
||
const ownBackground = resolveColor(effectiveStyle(block, deviceName, 'backgroundColor').value);
|
||
const foreground = ownForeground ?? resolveColor(schema.base.color);
|
||
const background = ownBackground ?? resolveColor(schema.base.backgroundColor);
|
||
if (!foreground || !background) return null;
|
||
const ratio = contrastRatio(foreground, background);
|
||
const rounded = Math.round(ratio * 10) / 10;
|
||
// Say what was compared: an ancestor section may set a different background than the theme,
|
||
// which this panel cannot see. A wrong-looking number is worse than an honest one.
|
||
const basis = ownBackground ? `Kontrast ${rounded}:1` : `Kontrast ${rounded}:1 gegen die Grundfarbe der Website`;
|
||
if (ratio >= 4.5) return element('p', 'contrast-note contrast-ok', `${basis} – erfüllt AA für jede Textgröße.`);
|
||
if (ratio >= 3) return element('p', 'contrast-note contrast-warn', `${basis} – reicht nur für großen Text ab 24 px oder 18,7 px fett. Für Fließtext werden 4,5:1 verlangt.`);
|
||
return element('p', 'contrast-note contrast-bad', `${basis} – zu wenig. AA verlangt 4,5:1 für Fließtext und 3:1 für großen Text.`);
|
||
};
|
||
|
||
const mediaControl = (key, value, apply) => {
|
||
const wrap = element('div', 'media-control');
|
||
const status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
|
||
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||
choose.type = 'button';
|
||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||
wrap.append(status, choose);
|
||
if (value) {
|
||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||
remove.type = 'button';
|
||
remove.addEventListener('click', () => apply(''));
|
||
wrap.append(remove);
|
||
}
|
||
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 if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||
else continue;
|
||
|
||
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(''),
|
||
}));
|
||
}
|
||
}
|
||
if (group === 'text') {
|
||
const notice = contrastNotice(block, deviceName);
|
||
if (notice) body.append(notice);
|
||
}
|
||
details.append(body);
|
||
panel.append(details);
|
||
}
|
||
|
||
// ---- reuse: copy between blocks, save as a named preset, apply a preset ----
|
||
const reuse = element('div', 'design-reuse');
|
||
reuse.append(element('p', 'design-group-label', 'Gestaltung wiederverwenden'));
|
||
|
||
const copy = element('button', 'secondary', 'Gestaltung kopieren');
|
||
copy.type = 'button';
|
||
copy.disabled = !block.style;
|
||
copy.addEventListener('click', () => { clipboard = structuredClone(block.style ?? null); onChange(); });
|
||
const paste = element('button', 'secondary', 'Gestaltung einfügen');
|
||
paste.type = 'button';
|
||
paste.disabled = clipboard === null;
|
||
paste.addEventListener('click', () => change(doc => applyStyle(doc, getSelection(), clipboard)));
|
||
reuse.append(copy, paste);
|
||
|
||
if (schema.presets.length > 0) {
|
||
const select = document.createElement('select');
|
||
select.id = 'style-preset';
|
||
select.setAttribute('aria-label', 'Gespeicherte Gestaltungsvorlage');
|
||
select.append(new Option('Vorlage anwenden …', ''));
|
||
for (const preset of schema.presets) select.append(new Option(preset.name, preset.key));
|
||
select.addEventListener('change', () => {
|
||
const preset = schema.presets.find(entry => entry.key === select.value);
|
||
select.value = '';
|
||
if (preset) change(doc => applyStyle(doc, getSelection(), preset.style));
|
||
});
|
||
reuse.append(select);
|
||
}
|
||
|
||
const save = element('button', 'secondary', 'Als Vorlage speichern');
|
||
save.type = 'button';
|
||
save.disabled = !block.style;
|
||
save.addEventListener('click', () => onSavePreset(structuredClone(block.style)));
|
||
reuse.append(save);
|
||
panel.append(reuse);
|
||
|
||
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);
|
||
}
|
||
}
|