Files
KantSites/public/assets/design-panel.js
T
kantarciandClaude Opus 5 270cbda9ac Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen
Inline-Textbearbeitung ohne Skript in der Vorschau: der Iframe führt
weiterhin kein eigenes JavaScript aus, der Editor greift in sein
Dokument und setzt das Element auf contenteditable. Während der
Bearbeitung wird die Vorschau nicht neu geladen, sonst wäre der
Cursor weg; Eingaben werden zu einem Undo-Schritt zusammengefasst.
Neben dem Doppelklick startet eine Schaltfläche dasselbe ohne Maus.

Kontrast wird nach WCAG 2.1 berechnet und als Warnung in drei Stufen
gemeldet, nicht als Verbot - der Plan verlangt Prüfung, nicht
Bevormundung.

Gestaltungsvorlagen liegen im Theme und gelten seitenübergreifend.
Sie werden mit demselben Compiler geprüft wie ein Blockstil, also
kann über diesen Weg kein Wert hereinkommen, für den ein Block
abgewiesen würde. Dazu Kopieren und Einfügen zwischen Blöcken und
Bildauswahl für Hintergrundbilder über die Mediathek.

Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Lint/Format/Container. Erstmals wurde der
neue Editor im laufenden Betrieb bedient: Direktbearbeitung und
Kontrastwarnung mit echten Werten ausgelöst, Teständerungen
verworfen. Weiterhin keine axe- oder Lighthouse-Messung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:20:01 +02:00

370 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 foreground = resolveColor(effectiveStyle(block, deviceName, 'color').value);
const background = resolveColor(effectiveStyle(block, deviceName, 'backgroundColor').value);
if (!foreground || !background) return null;
const ratio = contrastRatio(foreground, background);
const rounded = Math.round(ratio * 10) / 10;
if (ratio >= 4.5) return element('p', 'contrast-note contrast-ok', `Kontrast ${rounded}:1 – erfüllt AA für jede Textgröße.`);
if (ratio >= 3) return element('p', 'contrast-note contrast-warn', `Kontrast ${rounded}:1 – 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', `Kontrast ${rounded}:1 – 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);
}
}