Umut fragte nach Zeilen, nach Rahmen und Rundungen - und ob der Designer so aufgestellt ist wie Flatsome. Der Vergleich steht in docs/DESIGNER-VERGLEICH.md, mit der Einschraenkung, dass Flatsome hier nicht installierbar war: das ist Wissen, keine Messung. Zwei echte Fehler kamen dabei heraus, beide vorher unbemerkt. Eine Rahmenstaerke je Seite wurde zu border-width-top. Das ist kein CSS, der Browser verwirft die Zeile stillschweigend - Rahmenstaerken je Seite hatten nie eine Wirkung, und es gab keine Meldung, die darauf hingewiesen haette. Die Schreibweise steht jetzt je Eigenschaft im Schema, weil padding-top dem Namen folgt und border-top-width nicht. Und eine Rundung auf einem Bildblock landete auf dem figure, waehrend das Bild darin eckig blieb. Eigenschaften koennen jetzt auf das Bild im Block zielen (target 'img' im Schema, eine zweite Regel im Compiler) - deshalb tut object-fit ueberhaupt etwas, denn auf einem figure tut es nichts. Von 50 auf 93 Eigenschaften: Bild (Ausschnitt, Bildmitte, Seitenverhaeltnis, Hoehe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und Spaltenabstaende, Flex je Element, Innen- und Textschatten, acht Bildfilter als eine filter-Deklaration in fester Reihenfolge, Mischmodus, Zusatzrahmen, Mauszeiger. Neun Einblendungen mit Verzoegerung, sieben Hover-Effekte, alle innerhalb von prefers-reduced-motion. Drei neue Bausteine (Dokumentschema v15): Symbol als eigenes Element, Fortschritt mit role=meter und dem Wert im Markup statt nur in der Balkenbreite, und ein Slider als Scrollbereich mit Einrasten - ohne JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur. Zeilen waren nicht unauffindbar, sondern unbrauchbar leer: eine Zeile ohne Spalten sieht aus wie nichts. Jetzt legt "Zeile, 2/3/4 Spalten" beides auf einen Klick an, und im Werkzeugkasten steht, wie der Aufbau gemeint ist. 315 PHP-Tests / 1309 Assertions, 16 JS-Modelltests, axe auf dreizehn Bildschirmen ohne Verstoesse. 16 Datensaetze rechnen jede Eigenschaftsform gegen die erwartete Deklaration nach - die Pruefung, die die beiden Fehler oben gefunden haette, wenn es sie vorher gegeben haette. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
18 KiB
JavaScript
376 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',
|
||
image: 'Bild',
|
||
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);
|
||
}
|
||
}
|