Files
KantSites/public/assets/design-panel.js
T
kantarciandClaude Opus 5 67b85751cf
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Randlose Abschnitte, volle Bildschirmhoehe, Vollbild-Hero
Umuts Frage: "Haben wir bilder usw als volles bild startseite? 100% hoehe
usw?" Die Teile gab es, die Kombination nicht - und zwei Sachen fehlten
wirklich.

Abschnittsbreiten: neu "randlos" (ohne Begrenzung, ohne Rand, von Kante zu
Kante) und "breit" (1600 px) neben normal und schmal. Vorher war jeder Inhalt
auf 1180 px begrenzt; ein Bild von Kante zu Kante war nicht moeglich.

Hoeheneinheiten: neu dvh, svh, lvh. 100vh ist auf dem Handy hoeher als der
Bildschirm - es meint die Ansicht ohne Adressleiste, und der untere Rand
verschwindet darunter. 100dvh ist das, was jemand meint, der "volle Hoehe"
sagt.

Und eine Vorlage "Vollbild-Hero", die die ganze Kombination auf einen Klick
setzt: randlos, 100dvh (mobil 80dvh), Inhalt senkrecht zentriert,
Hintergrundbild formatfuellend, dunkle Ueberlagerung damit der Text lesbar
bleibt, Ueberschrift, Text, Button. Die Teile waren alle da, aber diese
Kombination zu finden war eine Recherche.

328 PHP-Tests / 1329 Assertions, 17 JS-Modelltests. Geprueft: vier Breiten
angenommen und eine erfundene abgewiesen, sieben Hoehenangaben mit dvh/svh/lvh
und zwei ungueltige, ein randloser Abschnitt mit formatfuellendem Bild, und
die Hero-Vorlage Eigenschaft fuer Eigenschaft.

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

406 lines
19 KiB
JavaScript
Raw Permalink 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',
image: 'Bild',
layout: 'Layout',
effect: 'Wirkung',
};
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'dvh', 'vh', 'svh', 'lvh', 'dvw', '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|dvh|svh|lvh|dvw)$/.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.'));
// A filter over the properties. With more than ninety of them across eight collapsed groups,
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
const search = element('input', 'design-search');
search.type = 'search';
search.id = 'design-search';
search.placeholder = 'Eigenschaft suchen, z. B. Rahmen …';
const searchLabel = element('label', 'search-label', 'Eigenschaft suchen');
searchLabel.htmlFor = search.id;
const found = element('p', 'field-help');
search.addEventListener('input', () => {
const term = search.value.trim().toLowerCase();
let matches = 0;
for (const groupElement of panel.querySelectorAll('.design-group')) {
let visible = 0;
for (const entry of groupElement.querySelectorAll('.design-row, .side-wrap, .design-group-label')) {
// A per-side control is a heading plus a block of fields; they travel together.
const text = (entry.classList.contains('side-wrap') ? entry.previousElementSibling?.textContent : entry.textContent) ?? '';
const hit = '' === term || text.toLowerCase().includes(term);
entry.hidden = !hit;
if (hit) visible++;
}
groupElement.hidden = '' !== term && 0 === visible;
if (!groupElement.hidden && '' !== term) groupElement.open = true;
matches += visible;
}
found.textContent = '' === term ? '' : (0 === matches ? 'Nichts gefunden. Unter „Erweitert“ gibt es außerdem eigenes CSS für dieses Element.' : `${matches} Treffer.`);
});
panel.append(searchLabel, search, found);
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);
}
}