618 lines
31 KiB
JavaScript
618 lines
31 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, 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',
|
||
};
|
||
|
||
// 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 }) {
|
||
/**
|
||
* 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 ? 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;
|
||
};
|
||
|
||
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');
|
||
renderedGroups.set(groupKey, details);
|
||
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, 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 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),
|
||
}));
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|