The icon set grows from 33 to 98 drawings in seven categories (arrows, contact, commerce, files and media, people and security, house and trade, general), including truck, cart, euro, camera, tools and solar. icons.js is the single source; npm run build:icons writes the Twig drawings and the validator list, and a test keeps them equal. Choosing an icon no longer fills the panel with tiles: the panel shows the current icon with "Change", which opens a modal with category tabs and named tiles, keyboard navigable, closing with Escape. Button, icon and icon card share it. A new style group "Symbol" targets the icon inside a button per device and on hover: colour, background, size, padding, radius, rotation, plus the gap to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only button whose label stays as the accessible name and keeps the line height. ADR 109. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
660 lines
33 KiB
JavaScript
660 lines
33 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, scrolledStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||
import { openMedia, createMediaPreview } from './media.js';
|
||
import { createColorControl } from './color-control.js';
|
||
|
||
export const GROUPS = {
|
||
text: 'Typografie',
|
||
background: 'Hintergrund',
|
||
spacing: 'Abstand',
|
||
size: 'Größe',
|
||
border: 'Rahmen',
|
||
image: 'Bild',
|
||
layout: 'Layout',
|
||
effect: 'Wirkung',
|
||
menu: 'Menü-Knopf',
|
||
slider: 'Slider-Steuerung',
|
||
icon: 'Symbol',
|
||
};
|
||
|
||
// 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);
|
||
}
|
||
|
||
/**
|
||
* Makes a number field draggable sideways, the way the Flatsome scrubfield works.
|
||
*
|
||
* Typing "24" into a field to see whether 24 is right, then "26", then "22", is three round
|
||
* trips for one decision. Dragging answers it in one motion. Typing and the arrow keys keep
|
||
* working untouched - this is added, nothing is taken away.
|
||
*
|
||
* Only for mouse and pen: on a touch screen a horizontal drag over a field is how the panel is
|
||
* scrolled, and stealing that would make the panel unusable on a phone.
|
||
*/
|
||
function makeScrubbable(input, { apply, step, mergeKey }) {
|
||
const size = 'any' === step || !step ? 1 : Number(step);
|
||
input.classList.add('scrubbable');
|
||
let startX = 0, startValue = 0, active = false, moved = false;
|
||
input.addEventListener('pointerdown', event => {
|
||
if (0 !== event.button || input.disabled || 'touch' === event.pointerType) return;
|
||
startX = event.clientX;
|
||
startValue = '' === input.value ? 0 : Number(input.value);
|
||
active = Number.isFinite(startValue);
|
||
moved = false;
|
||
});
|
||
input.addEventListener('pointermove', event => {
|
||
if (!active) return;
|
||
const distance = event.clientX - startX;
|
||
// A few pixels of slack, so a plain click still lands in the field to type in.
|
||
if (!moved && Math.abs(distance) < 3) return;
|
||
if (!moved) { moved = true; input.setPointerCapture(event.pointerId); }
|
||
event.preventDefault();
|
||
// Shift is coarse, Alt is fine - the two speeds a value needs while being judged by eye.
|
||
const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
|
||
const raw = startValue + distance * size * scale;
|
||
const rounded = Math.round(raw / (size * scale)) * (size * scale);
|
||
const min = '' === input.min ? -Infinity : Number(input.min);
|
||
const max = '' === input.max ? Infinity : Number(input.max);
|
||
const value = Math.min(max, Math.max(min, Math.round(rounded * 1000) / 1000));
|
||
if (String(value) === input.value) return;
|
||
input.value = String(value);
|
||
apply(String(value), mergeKey);
|
||
});
|
||
const stop = event => {
|
||
if (!active) return;
|
||
active = false;
|
||
if (!moved) return;
|
||
if (input.hasPointerCapture?.(event.pointerId)) input.releasePointerCapture(event.pointerId);
|
||
// The drag was one decision, so it closes as one entry in the history.
|
||
apply(input.value, null);
|
||
};
|
||
input.addEventListener('pointerup', stop);
|
||
input.addEventListener('pointercancel', stop);
|
||
input.title = 'Zum Ändern seitwärts ziehen. Umschalt grob, Alt fein.';
|
||
}
|
||
|
||
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, onDevice, getSelection, getHistory, getAreaKind = () => 'page' }) {
|
||
/**
|
||
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
|
||
*
|
||
* `keepPanel` is what makes dragging possible at all: a change normally re-renders this panel,
|
||
* which replaces the very field the pointer is holding on to. The drag would end after its
|
||
* first pixel. While scrubbing, the document and the preview are updated and the panel is left
|
||
* standing; it is redrawn once, when the pointer is released.
|
||
*/
|
||
const change = (mutate, mergeKey, keepPanel = false) => {
|
||
getHistory().change(mutate, mergeKey);
|
||
onChange({ keepPanel });
|
||
};
|
||
// Whether the sides of a property move together. This has to outlive the panel: switching the
|
||
// chain triggers a change, a change re-renders the panel, and a state kept in the button alone
|
||
// would be gone by the time the next keystroke arrives.
|
||
const linkedSides = new Map();
|
||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||
let clipboard = null;
|
||
let showAll = false, lastBlockId = null;
|
||
const openGroups = new Map();
|
||
const renderedGroups = new Map();
|
||
|
||
/** 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);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
/** Short marks for the chips; the full device name is in the title and the accessible name. */
|
||
const SHORT = { desktop: 'D', tablet: 'T', mobile: 'M' };
|
||
|
||
/** Does this device carry an own value for this property - as a plain value or per side? */
|
||
const hasOwn = (block, deviceName, key, hover) => {
|
||
const bucket = hover === 'scrolled' ? block?.style?.[deviceName]?.scrolled : hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
|
||
const value = bucket?.[key];
|
||
if (value === undefined) return false;
|
||
// A per-side property stores an object; an empty one is a leftover, not a value.
|
||
return value && typeof value === 'object' ? Object.values(value).some(single => single !== '' && single !== undefined) : value !== '';
|
||
};
|
||
|
||
/**
|
||
* Three chips per option showing which devices carry an own value, and switching to one.
|
||
*
|
||
* Until now a single switch above the preview decided which device the whole panel edited,
|
||
* so "is the heading smaller on the phone" could only be answered by switching and looking.
|
||
* The chips answer it where the question comes up. Clicking one switches the preview as well:
|
||
* editing a value for a device you are not looking at is editing blind.
|
||
*/
|
||
const deviceChips = (block, key, labelText, hover) => {
|
||
const current = getDevice();
|
||
const group = element('div', 'device-chips');
|
||
group.setAttribute('role', 'group');
|
||
group.setAttribute('aria-label', `Gerät für ${labelText}`);
|
||
for (const [name, deviceLabel] of Object.entries(devices)) {
|
||
const own = hasOwn(block, name, key, hover);
|
||
const chip = element('button', 'device-chip' + (own ? ' device-chip-own' : ''), SHORT[name]);
|
||
chip.type = 'button';
|
||
chip.dataset.device = name;
|
||
chip.setAttribute('aria-pressed', String(name === current));
|
||
const state = own ? 'eigener Wert' : 'erbt';
|
||
chip.title = `${deviceLabel} – ${state}`;
|
||
chip.setAttribute('aria-label', `${labelText} für ${deviceLabel} bearbeiten – ${state}`);
|
||
chip.addEventListener('click', () => { if (name !== current) onDevice(name); });
|
||
group.append(chip);
|
||
}
|
||
return group;
|
||
};
|
||
|
||
/** 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, chips }) => {
|
||
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 (chips) head.append(chips);
|
||
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, label, inheritedValue = '') => createColorControl({
|
||
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||
onComplete: () => {
|
||
const focusedId = document.activeElement?.id;
|
||
onChange();
|
||
if (focusedId) document.getElementById(focusedId)?.focus();
|
||
},
|
||
});
|
||
|
||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||
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 = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
|
||
number.addEventListener('change', () => push());
|
||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
|
||
wrap.append(number, unit);
|
||
if (keywords.length) {
|
||
const mode = document.createElement('select');
|
||
mode.className = 'length-mode';
|
||
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
|
||
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
|
||
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
|
||
mode.value = ['auto', 'none'].includes(value) ? value : '';
|
||
number.hidden = unit.hidden = mode.value !== '';
|
||
mode.addEventListener('change', () => apply(mode.value));
|
||
wrap.prepend(mode);
|
||
}
|
||
return wrap;
|
||
};
|
||
|
||
/**
|
||
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
|
||
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
|
||
*/
|
||
const fractionsControl = (id, value, apply) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.id = id;
|
||
input.value = value ?? '';
|
||
input.placeholder = 'gleich breit';
|
||
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
|
||
input.setAttribute('inputmode', 'decimal');
|
||
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
|
||
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
|
||
return input;
|
||
};
|
||
|
||
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); });
|
||
makeScrubbable(exact, {
|
||
apply: (value, mergeKey) => { slider.value = value; apply(value, mergeKey); },
|
||
step: property.step,
|
||
mergeKey: `scrub-${id}`,
|
||
});
|
||
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;
|
||
};
|
||
|
||
/**
|
||
* Padding, margin, border width and corners as a box.
|
||
*
|
||
* Four stacked fields labelled "Oben, Rechts, Unten, Links" made you read to find out which
|
||
* edge you were setting. In a box each field sits on the edge it controls, so the position is
|
||
* the label. The chain in the middle keeps all four equal, which is what most of them are.
|
||
*
|
||
* The unit stays a single choice for the whole property, because four unit menus do not fit a
|
||
* 240-pixel panel. Sides that already carry different units are shown as "gemischt" and are
|
||
* left alone until a unit is actually picked - a stored 2rem is not silently turned into 2px.
|
||
*/
|
||
const sideControl = (key, block, deviceName, property) => {
|
||
const corners = Boolean(property.corners);
|
||
const parts = corners ? schema.corners : schema.sides;
|
||
const names = Object.keys(parts);
|
||
const wrap = element('div', 'side-wrap');
|
||
const box = element('div', 'side-box' + (corners ? ' side-box-corners' : ''));
|
||
|
||
// What is stored right now, per side, split into number and unit.
|
||
const stored = Object.fromEntries(names.map(side => {
|
||
const current = effectiveStyle(block, deviceName, key, side);
|
||
const own = current.source === deviceName ? current.value : '';
|
||
return [side, { own, inherited: current.value, parts: splitLength(own) }];
|
||
}));
|
||
const usedUnits = [...new Set(names.map(side => stored[side].own ? stored[side].parts.unit : null).filter(Boolean))];
|
||
const commonUnit = 1 === usedUnits.length ? usedUnits[0] : '';
|
||
|
||
const unit = document.createElement('select');
|
||
unit.id = `style-${key}-unit`;
|
||
unit.className = 'side-unit-select';
|
||
unit.setAttribute('aria-label', `Einheit für ${property.label}`);
|
||
if (usedUnits.length > 1) unit.append(new Option('gemischt', ''));
|
||
for (const name of UNITS) unit.append(new Option(name, name));
|
||
unit.value = commonUnit || 'px';
|
||
|
||
const linked = element('button', 'side-link', '⛓');
|
||
linked.type = 'button';
|
||
// Off to begin with. It replaces the old "Alle Seiten gleich setzen" button, and that was a
|
||
// deliberate act: typing into one field must not quietly rewrite the three others.
|
||
linked.setAttribute('aria-pressed', String(linkedSides.get(key) ?? false));
|
||
linked.title = 'Alle Seiten gemeinsam ändern';
|
||
linked.setAttribute('aria-label', `Alle Seiten von ${property.label} gemeinsam ändern`);
|
||
|
||
/** The unit a side should be written with: its own, else the chosen one. */
|
||
const unitFor = side => (stored[side].own ? stored[side].parts.unit : null) ?? (unit.value || 'px');
|
||
|
||
const write = (side, number, mergeKey) => {
|
||
const targets = linkedSides.get(key) ? names : [side];
|
||
change(doc => {
|
||
for (const target of targets) {
|
||
setStyle(doc, getSelection(), deviceName, key, '' === number ? '' : `${number}${unitFor(target)}`, { side: target });
|
||
}
|
||
}, mergeKey, Boolean(mergeKey));
|
||
};
|
||
|
||
for (const side of names) {
|
||
const id = `style-${key}-${side}`;
|
||
const field = document.createElement('input');
|
||
field.type = 'number';
|
||
field.step = 'any';
|
||
field.id = id;
|
||
field.className = 'side-field';
|
||
field.value = stored[side].parts.number;
|
||
field.placeholder = stored[side].inherited ? String(stored[side].inherited) : 'erbt';
|
||
field.setAttribute('aria-label', `${property.label} ${parts[side]}`);
|
||
if (stored[side].inherited && !stored[side].own) field.classList.add('is-inherited');
|
||
field.addEventListener('change', () => write(side, field.value, null));
|
||
makeScrubbable(field, { apply: (value, mergeKey) => write(side, value, mergeKey), step: 1, mergeKey: `scrub-${id}` });
|
||
const cell = element('div', `side-cell side-${side}`);
|
||
cell.append(field);
|
||
box.append(cell);
|
||
}
|
||
box.append(linked);
|
||
|
||
linked.addEventListener('click', () => {
|
||
const now = !linkedSides.get(key);
|
||
linkedSides.set(key, now);
|
||
linked.setAttribute('aria-pressed', String(now));
|
||
if (!now) return;
|
||
// Switching the chain on levels the sides - otherwise it would claim a state the box does
|
||
// not show. The value taken is the first one that exists: starting from an empty top field
|
||
// would wipe the sides that do have a value.
|
||
const set = names.map(side => stored[side].parts.number).find(number => '' !== number);
|
||
if (undefined === set) return;
|
||
change(doc => {
|
||
for (const target of names) setStyle(doc, getSelection(), deviceName, key, `${set}${unit.value || 'px'}`, { side: target });
|
||
});
|
||
});
|
||
|
||
unit.addEventListener('change', () => {
|
||
if ('' === unit.value) return;
|
||
change(doc => {
|
||
for (const side of names) {
|
||
const number = stored[side].own ? stored[side].parts.number : '';
|
||
if ('' !== number) setStyle(doc, getSelection(), deviceName, key, `${number}${unit.value}`, { side });
|
||
}
|
||
});
|
||
});
|
||
|
||
const unitRow = element('div', 'side-unit');
|
||
const unitLabel = element('label', null, 'Einheit');
|
||
unitLabel.htmlFor = unit.id;
|
||
unitRow.append(unitLabel, unit);
|
||
wrap.append(box, unitRow);
|
||
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 hasImage = Boolean(value && value !== 'none');
|
||
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
|
||
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||
choose.type = 'button';
|
||
choose.id = `style-${key}`; choose.setAttribute('aria-label', choose.textContent);
|
||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||
wrap.append(preview.element, choose);
|
||
if (hasImage) {
|
||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||
remove.type = 'button';
|
||
remove.title = 'Hintergrundbild für dieses Gerät entfernen. Die Datei bleibt in der Mediathek.';
|
||
remove.addEventListener('click', () => { apply('none'); document.getElementById(choose.id)?.focus(); });
|
||
wrap.append(remove);
|
||
}
|
||
return wrap;
|
||
};
|
||
|
||
/** Renders the whole panel for one block into the given container. */
|
||
return function render(panel) {
|
||
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
|
||
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
|
||
renderedGroups.clear();
|
||
const block = getBlock();
|
||
const deviceName = getDevice();
|
||
if (!block) return;
|
||
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
|
||
|
||
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.'));
|
||
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
|
||
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
|
||
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
|
||
panel.append(all);
|
||
|
||
// 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);
|
||
|
||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||
for (const group of orderedGroups) {
|
||
const groupLabel = GROUPS[group];
|
||
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
|
||
if (properties.length === 0) continue;
|
||
const details = element('details', 'design-group');
|
||
const groupKey = `${block.type}:${group}`;
|
||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing' || group === 'icon');
|
||
renderedGroups.set(groupKey, details);
|
||
details.append(element('summary', null, groupLabel)); details.dataset.group = group;
|
||
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, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value), mergeKey, Boolean(mergeKey));
|
||
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) {
|
||
const sideHead = element('div', 'design-row-head design-side-head');
|
||
sideHead.append(element('p', 'design-group-label', property.label), deviceChips(block, key, property.label, false));
|
||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||
continue;
|
||
}
|
||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
|
||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||
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, current.value || '', apply);
|
||
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
|
||
else continue;
|
||
|
||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||
|
||
if (schema.hoverable.includes(key)) {
|
||
const hoverId = `${id}-hover`;
|
||
const hoverValue = hoverStyle(block, deviceName, key);
|
||
let hoverInherited = current.value || '';
|
||
for (const name of Object.keys(devices)) {
|
||
if (name === deviceName) break;
|
||
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
|
||
}
|
||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||
const hoverControl = property.type === 'color'
|
||
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||
: 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(''),
|
||
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
|
||
}));
|
||
}
|
||
|
||
// The scrolled page - only in the header, the one area that changes with scrolling.
|
||
if (getAreaKind() === 'header' && (schema.scrollable ?? []).includes(key)) {
|
||
const scrolledId = `${id}-scrolled`;
|
||
const scrolledValue = scrolledStyle(block, deviceName, key);
|
||
let scrolledInherited = current.value || '';
|
||
for (const name of Object.keys(devices)) {
|
||
if (name === deviceName) break;
|
||
scrolledInherited = scrolledStyle(block, name, key) ?? scrolledInherited;
|
||
}
|
||
const scrolledApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { scrolled: true }), mergeKey, Boolean(mergeKey));
|
||
const scrolledControl = property.type === 'color'
|
||
? colorControl(scrolledId, scrolledValue ?? '', scrolledApply, `${property.label} beim Scrollen`, scrolledInherited)
|
||
: numberControl(scrolledId, scrolledValue ?? '', property, scrolledApply);
|
||
body.append(row(scrolledId, `${property.label} beim Scrollen`, scrolledControl, {
|
||
own: scrolledValue !== undefined,
|
||
inherited: 'Gilt, sobald die Seite um mehr als 40 px gescrollt ist. Vorher gilt der normale Wert - für einen transparenten Kopf dort #ffffff00 eintragen.',
|
||
onReset: () => scrolledApply(''),
|
||
chips: deviceChips(block, key, `${property.label} beim Scrollen`, 'scrolled'),
|
||
}));
|
||
}
|
||
}
|
||
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 === 'media') return value === 'none' ? 'Kein Bild' : 'Bild aus der Mediathek';
|
||
if (property.type === 'choice') return property.values[value] ?? value;
|
||
return String(value);
|
||
}
|
||
}
|