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>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 13:20:01 +02:00
1 parent abf0bd2c6b
commit 270cbda9ac
11 files changed
+388 -10

No files matched your search

+116 -2
View File
@@ -6,6 +6,7 @@
* 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',
@@ -26,6 +27,21 @@ function splitLength(value) {
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;
@@ -33,8 +49,27 @@ function element(tag, className, text) {
return node;
}
export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSelection, getHistory }) {
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 }) => {
@@ -174,6 +209,45 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
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();
@@ -213,7 +287,8 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
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
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 }));
@@ -233,10 +308,49 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
}));
}
}
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';