/** * The design panel. * * Every control is generated from the style schema the server sends, so the editor offers exactly * what the server accepts – nothing more, nothing less. Adding a property on the server makes it * appear here without touching this file. */ import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js'; export const GROUPS = { text: 'Typografie', background: 'Hintergrund', spacing: 'Abstand', size: 'Größe', border: 'Rahmen', layout: 'Layout', effect: 'Wirkung', }; const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch']; /** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */ function splitLength(value) { const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? ''); if (match) return { number: match[1], unit: match[2] }; return { number: value === '0' ? '0' : '', unit: 'px' }; } 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, getBlock, getDevice, getSelection, getHistory }) { const change = mutate => { getHistory().change(mutate); onChange(); }; /** One labelled row with a reset button that appears as soon as this device has an own value. */ const row = (id, labelText, control, { own, inherited, onReset }) => { const wrap = element('div', 'design-row' + (own ? ' design-own' : '')); const head = element('div', 'design-row-head'); const label = element('label', null, labelText); label.htmlFor = id; head.append(label); if (own) { const reset = element('button', 'text-button design-reset-one', '⟲'); reset.type = 'button'; reset.title = 'Auf geerbten Wert zurücksetzen'; reset.setAttribute('aria-label', `${labelText} zurücksetzen`); reset.addEventListener('click', onReset); head.append(reset); } wrap.append(head, control); if (!own && inherited) wrap.append(element('p', 'design-inherited', inherited)); return wrap; }; const colorControl = (id, value, apply) => { const wrap = element('div', 'color-control'); const picker = document.createElement('input'); picker.type = 'color'; picker.id = id; picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000'; const text = document.createElement('input'); text.type = 'text'; text.className = 'color-text'; text.value = value ?? ''; text.placeholder = '#1a2b3c'; text.setAttribute('aria-label', 'Farbwert oder Palettenname'); const palette = document.createElement('select'); palette.className = 'color-palette'; palette.setAttribute('aria-label', 'Farbe aus der Palette'); palette.append(new Option('Palette …', '')); for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`)); palette.value = (value ?? '').startsWith('@') ? value : ''; picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); }); text.addEventListener('change', () => apply(text.value.trim())); palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } }); wrap.append(picker, text, palette); return wrap; }; const lengthControl = (id, value, apply, placeholder) => { const wrap = element('div', 'length-control'); const parts = splitLength(value); const number = document.createElement('input'); number.type = 'number'; number.id = id; number.step = 'any'; number.value = parts.number; number.placeholder = placeholder ?? 'erbt'; const unit = document.createElement('select'); unit.setAttribute('aria-label', 'Einheit'); for (const name of UNITS) unit.append(new Option(name, name)); unit.value = parts.unit; const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`); number.addEventListener('change', push); unit.addEventListener('change', () => { if (number.value !== '') push(); }); wrap.append(number, unit); return wrap; }; const numberControl = (id, value, property, apply) => { const wrap = element('div', 'number-control'); const slider = document.createElement('input'); slider.type = 'range'; slider.min = property.min; slider.max = property.max; slider.step = property.step; slider.value = value ?? property.min; slider.setAttribute('aria-label', `${property.label} Schieberegler`); const exact = document.createElement('input'); exact.type = 'number'; exact.id = id; exact.min = property.min; exact.max = property.max; exact.step = property.step; exact.value = value ?? ''; exact.placeholder = 'erbt'; slider.addEventListener('input', () => { exact.value = slider.value; }); slider.addEventListener('change', () => apply(slider.value)); exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); }); wrap.append(slider, exact); return wrap; }; const choiceControl = (id, value, property, apply, inheritedLabel) => { const select = document.createElement('select'); select.id = id; select.append(new Option(`Übernehmen: ${inheritedLabel}`, '')); for (const [key, label] of Object.entries(property.values)) select.append(new Option(label, key)); select.value = value ?? ''; select.addEventListener('change', () => apply(select.value)); return select; }; const fontControl = (id, value, apply, inheritedLabel) => { const select = document.createElement('select'); select.id = id; select.append(new Option(`Übernehmen: ${inheritedLabel}`, '')); for (const font of schema.fonts) select.append(new Option(`${font.name}${font.family ? ` · ${font.family}` : ''}`, font.key)); select.value = value ?? ''; select.addEventListener('change', () => apply(select.value)); return select; }; /** Four inputs for padding, margin, border width and corners. */ const sideControl = (key, block, deviceName, property) => { const grid = element('div', 'side-control'); const parts = property.corners ? schema.corners : schema.sides; for (const [side, label] of Object.entries(parts)) { const cell = element('div', 'side-cell'); const id = `style-${key}-${side}`; const caption = element('label', null, label); caption.htmlFor = id; const current = effectiveStyle(block, deviceName, key, side); const control = lengthControl(id, current.source === deviceName ? current.value : '', value => { change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side })); }, current.value ? String(current.value) : 'erbt'); if (current.value && current.source !== deviceName) control.classList.add('is-inherited'); cell.append(caption, control); grid.append(cell); } const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen'); all.type = 'button'; all.addEventListener('click', () => { const first = grid.querySelector('input[type=number]'); const unit = grid.querySelector('select'); if (first?.value === '') return; change(doc => { for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side }); }); }); const wrap = element('div', 'side-wrap'); wrap.append(grid, all); return wrap; }; /** Renders the whole panel for one block into the given container. */ return function render(panel) { const block = getBlock(); const deviceName = getDevice(); if (!block) return; const scope = element('p', 'design-scope'); scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`; panel.append(scope); panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.')); for (const [group, groupLabel] of Object.entries(GROUPS)) { const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group); if (properties.length === 0) continue; const details = element('details', 'design-group'); if (group === 'text' || group === 'spacing') details.open = true; details.append(element('summary', null, groupLabel)); const body = element('div', 'design-group-body'); for (const [key, property] of properties) { const id = `style-${key}`; const current = effectiveStyle(block, deviceName, key); const own = block.style?.[deviceName]?.[key] !== undefined; const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value)); const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, '')); const inheritedText = current.value !== undefined && !own ? `Erbt ${describe(property, current.value)} von ${devices[current.source]}` : 'Theme-Standard'; let control; if (property.sides || property.corners) { body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property)); continue; } if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply); else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt'); else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply); else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText); else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText); else continue; // media properties are set through the media library body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset })); if (schema.hoverable.includes(key)) { const hoverId = `${id}-hover`; const hoverValue = hoverStyle(block, deviceName, key); const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true })); const hoverControl = property.type === 'color' ? colorControl(hoverId, hoverValue ?? '', hoverApply) : property.type === 'number' ? numberControl(hoverId, hoverValue ?? '', property, hoverApply) : lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover'); body.append(row(hoverId, `${property.label} beim Überfahren`, hoverControl, { own: hoverValue !== undefined, inherited: 'Kein eigener Hover-Wert', onReset: () => hoverApply(''), })); } } details.append(body); panel.append(details); } const actions = element('div', 'design-actions'); const resetDevice = element('button', 'secondary', `${devices[deviceName]} zurücksetzen`); resetDevice.type = 'button'; resetDevice.addEventListener('click', () => change(doc => resetStyleDevice(doc, getSelection(), deviceName))); const resetAll = element('button', 'secondary danger-text', 'Gesamte Gestaltung entfernen'); resetAll.type = 'button'; resetAll.addEventListener('click', () => change(doc => clearStyle(doc, getSelection()))); actions.append(resetDevice, resetAll); panel.append(actions); }; function describe(property, value) { if (property.type === 'choice') return property.values[value] ?? value; return String(value); } }