Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen
Inline-Textbearbeitung ohne Skript in der Vorschau: der Iframe führt weiterhin kein eigenes JavaScript aus, der Editor greift in sein Dokument und setzt das Element auf contenteditable. Während der Bearbeitung wird die Vorschau nicht neu geladen, sonst wäre der Cursor weg; Eingaben werden zu einem Undo-Schritt zusammengefasst. Neben dem Doppelklick startet eine Schaltfläche dasselbe ohne Maus. Kontrast wird nach WCAG 2.1 berechnet und als Warnung in drei Stufen gemeldet, nicht als Verbot - der Plan verlangt Prüfung, nicht Bevormundung. Gestaltungsvorlagen liegen im Theme und gelten seitenübergreifend. Sie werden mit demselben Compiler geprüft wie ein Blockstil, also kann über diesen Weg kein Wert hereinkommen, für den ein Block abgewiesen würde. Dazu Kopieren und Einfügen zwischen Blöcken und Bildauswahl für Hintergrundbilder über die Mediathek. Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Erstmals wurde der neue Editor im laufenden Betrieb bedient: Direktbearbeitung und Kontrastwarnung mit echten Werten ausgelöst, Teständerungen verworfen. Weiterhin keine axe- oder Lighthouse-Messung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
abf0bd2c6b
commit
270cbda9ac
11 files changed
+388
-10
No files matched your search
@@ -59,3 +59,14 @@
|
||||
.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}
|
||||
|
||||
.contrast-note{margin:16px 0 0;padding:10px 12px;border-radius:7px;font-size:11px;line-height:1.5;font-weight:600}
|
||||
.contrast-ok{background:#edf3dc;color:#315b49;border:1px solid #c5d89a}
|
||||
.contrast-warn{background:#fff6e2;color:#78551d;border:1px solid #e6cf9c}
|
||||
.contrast-bad{background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7}
|
||||
.media-control{display:grid;gap:8px}
|
||||
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
|
||||
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
|
||||
.design-reuse button,.design-reuse select{width:100%}
|
||||
#preset-dialog label{margin-top:18px}
|
||||
#preset-dialog .field-error{margin:10px 0 0;font-size:12px;color:#8b2f22;font-weight:600}
|
||||
@@ -6,6 +6,7 @@
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -26,6 +27,21 @@ function splitLength(value) {
|
||||
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);
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -33,8 +49,27 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSelection, getHistory }) {
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
|
||||
const change = mutate => { getHistory().change(mutate); onChange(); };
|
||||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||||
let clipboard = null;
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 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 }) => {
|
||||
@@ -174,6 +209,45 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
|
||||
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 foreground = resolveColor(effectiveStyle(block, deviceName, 'color').value);
|
||||
const background = resolveColor(effectiveStyle(block, deviceName, 'backgroundColor').value);
|
||||
if (!foreground || !background) return null;
|
||||
const ratio = contrastRatio(foreground, background);
|
||||
const rounded = Math.round(ratio * 10) / 10;
|
||||
if (ratio >= 4.5) return element('p', 'contrast-note contrast-ok', `Kontrast ${rounded}:1 – erfüllt AA für jede Textgröße.`);
|
||||
if (ratio >= 3) return element('p', 'contrast-note contrast-warn', `Kontrast ${rounded}:1 – 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', `Kontrast ${rounded}:1 – 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 status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
|
||||
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
choose.type = 'button';
|
||||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||||
wrap.append(status, choose);
|
||||
if (value) {
|
||||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||||
remove.type = 'button';
|
||||
remove.addEventListener('click', () => apply(''));
|
||||
wrap.append(remove);
|
||||
}
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/** Renders the whole panel for one block into the given container. */
|
||||
return function render(panel) {
|
||||
const block = getBlock();
|
||||
@@ -213,7 +287,8 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
|
||||
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
|
||||
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
|
||||
|
||||
@@ -233,10 +308,49 @@ export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSe
|
||||
}));
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
+126
-1
@@ -10,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, designSchema, designPanel;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
|
||||
const collapsed = new Set();
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
@@ -173,6 +173,17 @@ function renderProperties() {
|
||||
return;
|
||||
}
|
||||
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||
if (INLINE_TEXT[block.type]) {
|
||||
const inline = document.createElement('button');
|
||||
inline.className = 'secondary full-width';
|
||||
inline.type = 'button';
|
||||
inline.textContent = 'Direkt in der Vorschau bearbeiten';
|
||||
inline.addEventListener('click', () => startInlineEdit(block.id));
|
||||
const note = document.createElement('p');
|
||||
note.className = 'field-help';
|
||||
note.textContent = 'Oder in der Vorschau doppelt auf den Text klicken. Escape beendet die Bearbeitung.';
|
||||
panel.append(inline, note);
|
||||
}
|
||||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||
if (block.type === 'iconcard') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
@@ -303,6 +314,89 @@ function chooseMedia(multiple) {
|
||||
} });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Inline text editing.
|
||||
//
|
||||
// The preview runs no script of its own (the iframe is sandboxed without allow-scripts), but the
|
||||
// editor can reach into its document. Editing therefore happens from here: the element is made
|
||||
// editable, typing goes straight into the model, and the preview is left alone until editing ends
|
||||
// so the caret is never pulled away mid-sentence.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Block type to the element inside it that carries the text, and the property it belongs to. */
|
||||
const INLINE_TEXT = {
|
||||
heading: { selector: null, prop: 'text' },
|
||||
text: { selector: null, prop: 'text', multiline: true },
|
||||
quote: { selector: 'blockquote p', prop: 'text', multiline: true },
|
||||
cta: { selector: 'h2', prop: 'title' },
|
||||
iconcard: { selector: 'h3', prop: 'title' },
|
||||
accordionitem: { selector: 'summary', prop: 'title' },
|
||||
tabitem: { selector: '.tabs-heading', prop: 'title' },
|
||||
stat: { selector: 'strong', prop: 'value' },
|
||||
};
|
||||
|
||||
let inlineEdit = null;
|
||||
|
||||
function inlineTarget(blockId) {
|
||||
const block = locate(history.document.blocks, blockId)?.block;
|
||||
const rule = block && INLINE_TEXT[block.type];
|
||||
if (!rule) return null;
|
||||
const root = $('#preview').contentDocument?.getElementById(blockId);
|
||||
if (!root) return null;
|
||||
const element = rule.selector ? root.querySelector(rule.selector) : root;
|
||||
return element ? { block, rule, element } : null;
|
||||
}
|
||||
|
||||
function startInlineEdit(blockId) {
|
||||
if (inlineEdit?.blockId === blockId) return;
|
||||
stopInlineEdit();
|
||||
const target = inlineTarget(blockId);
|
||||
if (!target || busy) return;
|
||||
const { rule, element } = target;
|
||||
element.setAttribute('contenteditable', 'plaintext-only');
|
||||
element.spellcheck = true;
|
||||
element.classList.add('editor-inline');
|
||||
element.focus();
|
||||
// Put the caret at the end instead of selecting everything, so typing appends.
|
||||
const range = element.ownerDocument.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = element.ownerDocument.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
const onInput = () => {
|
||||
const value = element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, blockId)?.block;
|
||||
if (current) current.props[rule.prop] = rule.multiline ? value : value.replace(/\s*\n+\s*/g, ' ');
|
||||
}, `inline-${blockId}-${rule.prop}`);
|
||||
controls();
|
||||
};
|
||||
const onKey = event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); stopInlineEdit(); }
|
||||
if (event.key === 'Enter' && !rule.multiline) { event.preventDefault(); stopInlineEdit(); }
|
||||
};
|
||||
element.addEventListener('input', onInput);
|
||||
element.addEventListener('keydown', onKey);
|
||||
element.addEventListener('blur', stopInlineEdit, { once: true });
|
||||
inlineEdit = { blockId, element, onInput, onKey };
|
||||
$('#preview-status').textContent = 'Direktbearbeitung · Escape beendet sie, Änderungen stehen sofort im Verlauf.';
|
||||
}
|
||||
|
||||
function stopInlineEdit() {
|
||||
if (!inlineEdit) return;
|
||||
const { element, onInput, onKey } = inlineEdit;
|
||||
inlineEdit = null;
|
||||
element.removeEventListener('input', onInput);
|
||||
element.removeEventListener('keydown', onKey);
|
||||
element.removeAttribute('contenteditable');
|
||||
element.classList.remove('editor-inline');
|
||||
history.mergeKey = null;
|
||||
// One refresh at the end, so the stored document and the preview agree again.
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
function resizePreview() {
|
||||
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||||
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
|
||||
@@ -316,6 +410,8 @@ function resizePreview() {
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
// Replacing the iframe content would destroy the caret, so editing wins until it ends.
|
||||
if (inlineEdit) return;
|
||||
clearTimeout(previewTimer);
|
||||
// Invalidate an in-flight response immediately, including during the debounce window.
|
||||
previewSequence++;
|
||||
@@ -338,9 +434,16 @@ async function updatePreview() {
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
|
||||
doc.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
if (inlineEdit) return;
|
||||
const element = event.target.closest('[id]');
|
||||
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||||
});
|
||||
doc.addEventListener('dblclick', event => {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element) return;
|
||||
const block = locate(history.document.blocks, element.id)?.block;
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
doc.addEventListener('submit', event => event.preventDefault());
|
||||
frame.contentWindow.scrollTo(0, top); highlight();
|
||||
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||
@@ -365,6 +468,7 @@ async function loadDesignSchema() {
|
||||
designPanel = createDesignPanel({
|
||||
schema: designSchema,
|
||||
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
|
||||
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
|
||||
getBlock: () => locate(history.document.blocks, selected)?.block,
|
||||
getDevice: () => device,
|
||||
getSelection: () => selected,
|
||||
@@ -465,6 +569,27 @@ $('#save').addEventListener('click', () => save());
|
||||
$('#publish')?.addEventListener('click', () => $('#publish-dialog').showModal());
|
||||
$('#cancel-publish').addEventListener('click', () => $('#publish-dialog').close());
|
||||
$('#confirm-publish').addEventListener('click', () => { $('#publish-dialog').close(); save(true); });
|
||||
$('#cancel-preset').addEventListener('click', () => $('#preset-dialog').close());
|
||||
$('#confirm-preset').addEventListener('click', async () => {
|
||||
const name = $('#preset-name').value.trim();
|
||||
const error = $('#preset-error');
|
||||
try {
|
||||
const response = await fetch('/admin/api/style-presets', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': designSchema.csrfToken },
|
||||
body: JSON.stringify({ name, style: pendingPreset }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error || 'Die Vorlage konnte nicht gespeichert werden.');
|
||||
// Rebuild the panel so the new preset is immediately offered.
|
||||
designSchema.presets = body.presets;
|
||||
$('#preset-dialog').close();
|
||||
renderProperties();
|
||||
} catch (problem) {
|
||||
error.textContent = problem.message;
|
||||
error.hidden = false;
|
||||
}
|
||||
});
|
||||
$('#reload').addEventListener('click', () => $('#reload-dialog').showModal());
|
||||
$('#cancel-reload').addEventListener('click', () => $('#reload-dialog').close());
|
||||
$('#confirm-reload').addEventListener('click', () => { $('#reload-dialog').close(); load(); });
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
/* Placeholder for a block whose plugin is missing. Editor preview only; the public page renders
|
||||
nothing for it, so this style never reaches a visitor. */
|
||||
.block-unavailable{margin:12px auto;max-width:640px;padding:14px 18px;border:2px dashed #b08a3c;border-radius:8px;background:#fdf6e6;color:#6c5220;font:600 13px/1.6 system-ui,sans-serif}
|
||||
|
||||
/* Inline editing. Only ever active in the editor preview, never on a published page. */
|
||||
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
|
||||
Reference in new issue
Block a user