Freie Gestaltung statt Tokenlisten (Dokumentschema v10)
Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a40c2bb27e
commit
abf0bd2c6b
24 files changed
+1591
-21
No files matched your search
@@ -22,3 +22,40 @@
|
||||
.submission-card{align-items:flex-start}.submission-data{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:12px 0 0;font-size:12px}.submission-data dt{font-weight:600;color:var(--muted)}.submission-data dd{margin:0;overflow-wrap:anywhere}.menu-table .check-cell{text-align:center}.menu-table .check-cell input{width:18px;min-height:18px}
|
||||
|
||||
.redirect-table{margin-top:26px}.redirect-table td{padding:8px 12px 8px 0;font-size:12px}.redirect-table form{margin:0}
|
||||
|
||||
/* Design panel: free values instead of fixed lists. */
|
||||
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
|
||||
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
.design-group>summary::after{content:'+';font-size:17px}
|
||||
.design-group[open]>summary{border-bottom:1px solid var(--line)}
|
||||
.design-group[open]>summary::after{content:'–'}
|
||||
.design-group-body{padding:6px 14px 16px}
|
||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
|
||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
||||
.color-control .color-text{font-size:12px;min-height:38px}
|
||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||
.length-control.is-inherited input{color:var(--muted)}
|
||||
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
|
||||
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
|
||||
.number-control input[type=number]{font-size:12px;min-height:38px}
|
||||
.side-wrap{margin-bottom:6px}
|
||||
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
|
||||
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
|
||||
.design-actions{display:grid;gap:8px;margin-top:22px}
|
||||
.design-actions button{width:100%}
|
||||
|
||||
.theme-form .theme-swatch{width:52px;min-height:38px;padding:2px;cursor:pointer}
|
||||
.theme-form .weights-cell{white-space:nowrap}
|
||||
.theme-form .weights-cell label{display:inline-flex;align-items:center;gap:3px;margin:0 8px 0 0;font-size:10px;font-weight:400}
|
||||
.theme-form .weights-cell input{width:14px;min-height:14px}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export function effectiveDesign(block, device, key) {
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
@@ -222,3 +222,92 @@ export class EditorHistory {
|
||||
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
|
||||
get dirty() { return JSON.stringify(this.document) !== this.saved; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Free block styles (document schema v10).
|
||||
//
|
||||
// A style is { device: { property: value, hover: { property: value } } }. Desktop is the base,
|
||||
// tablet and mobile override it. Everything mirrors StyleSchema on the server: the editor can only
|
||||
// offer what the server accepts, and a value the server would reject never reaches a document.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** The value that actually applies on a device, plus the device it was inherited from. */
|
||||
export function effectiveStyle(block, device, key, side = null) {
|
||||
let value, source;
|
||||
for (const name of Object.keys(devices)) {
|
||||
const stored = block?.style?.[name]?.[key];
|
||||
const candidate = side === null ? stored : stored?.[side];
|
||||
if (candidate !== undefined) { value = candidate; source = name; }
|
||||
if (name === device) break;
|
||||
}
|
||||
return { value, source };
|
||||
}
|
||||
|
||||
export function hoverStyle(block, device, key) {
|
||||
return block?.style?.[device]?.hover?.[key];
|
||||
}
|
||||
|
||||
/** Removes empty objects so an untouched block never carries a style skeleton. */
|
||||
function prune(block) {
|
||||
for (const device of Object.keys(block.style ?? {})) {
|
||||
const values = block.style[device];
|
||||
for (const key of Object.keys(values)) {
|
||||
if (values[key] && typeof values[key] === 'object' && Object.keys(values[key]).length === 0) delete values[key];
|
||||
}
|
||||
if (Object.keys(values).length === 0) delete block.style[device];
|
||||
}
|
||||
if (block.style && Object.keys(block.style).length === 0) delete block.style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
|
||||
* device and finally to the theme. Called inside a history transaction like every other change.
|
||||
*/
|
||||
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
const empty = value === '' || value === null || value === undefined;
|
||||
if (!empty) upgradeDocument(document, 10);
|
||||
|
||||
block.style ??= {};
|
||||
block.style[device] ??= {};
|
||||
let target = block.style[device];
|
||||
if (hover) { target.hover ??= {}; target = target.hover; }
|
||||
|
||||
if (side !== null) {
|
||||
if (empty) { if (target[key]) delete target[key][side]; }
|
||||
else { target[key] ??= {}; target[key][side] = value; }
|
||||
} else if (empty) {
|
||||
delete target[key];
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
prune(block);
|
||||
}
|
||||
|
||||
/** Drops every style of one device, so it inherits again. */
|
||||
export function resetStyleDevice(document, id, device) {
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block?.style) return;
|
||||
delete block.style[device];
|
||||
prune(block);
|
||||
}
|
||||
|
||||
/** Drops the whole styling of a block. */
|
||||
export function clearStyle(document, id) {
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
delete block.style;
|
||||
}
|
||||
|
||||
/** Copies the styling of one block onto another, with fresh objects. */
|
||||
export function copyStyle(document, fromId, toId) {
|
||||
const source = locate(document.blocks, fromId)?.block;
|
||||
const target = locate(document.blocks, toId)?.block;
|
||||
if (!source || !target) return false;
|
||||
if (!source.style) { delete target.style; return true; }
|
||||
upgradeDocument(document, 10);
|
||||
target.style = structuredClone(source.style);
|
||||
return true;
|
||||
}
|
||||
+40
-2
@@ -1,5 +1,6 @@
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
|
||||
@@ -9,7 +10,7 @@ const $ = selector => document.querySelector(selector);
|
||||
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
|
||||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel;
|
||||
const collapsed = new Set();
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
@@ -235,6 +236,24 @@ function renderProperties() {
|
||||
}
|
||||
|
||||
function renderDesign(block, panel) {
|
||||
if (designSchema && designPanel) {
|
||||
designPanel(panel);
|
||||
} else {
|
||||
panel.append(Object.assign(document.createElement('p'), { className: 'panel-note', textContent: 'Gestaltungsoptionen werden geladen …' }));
|
||||
}
|
||||
const hasLegacy = Boolean(block.layout?.design) || Boolean(block.layout?.effects);
|
||||
if (!hasLegacy) return;
|
||||
const details = document.createElement('details'); details.className = 'design-group';
|
||||
const summary = document.createElement('summary'); summary.textContent = 'Klassische Werte dieser Seite';
|
||||
const body = document.createElement('div'); body.className = 'design-group-body';
|
||||
details.append(summary, body);
|
||||
renderLegacyDesign(block, body);
|
||||
panel.append(details);
|
||||
}
|
||||
|
||||
function renderLegacyDesign(block, panel) {
|
||||
// Token based values from documents written before schema v10. Shown only while a block
|
||||
// still carries them, so older pages stay editable without cluttering the new panel.
|
||||
const hint = document.createElement('p'); hint.className = 'design-scope';
|
||||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
|
||||
@@ -338,6 +357,24 @@ function changed() {
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
async function loadDesignSchema() {
|
||||
try {
|
||||
const response = await fetch('/admin/api/design', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
designSchema = await response.json();
|
||||
designPanel = createDesignPanel({
|
||||
schema: designSchema,
|
||||
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
|
||||
getBlock: () => locate(history.document.blocks, selected)?.block,
|
||||
getDevice: () => device,
|
||||
getSelection: () => selected,
|
||||
getHistory: () => history,
|
||||
});
|
||||
} catch {
|
||||
// The editor stays usable without it; the panel says so.
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
busy = true; controls();
|
||||
try {
|
||||
@@ -347,6 +384,7 @@ async function load() {
|
||||
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
|
||||
designTokens = await response.json();
|
||||
}
|
||||
if (!designSchema) await loadDesignSchema();
|
||||
history = new EditorHistory(data.document);
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||||
|
||||
Reference in new issue
Block a user