/** * 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', }; // 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; }; 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), })); } // 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); } }