The share image and featured image of a page become image fields with the library dialog, as do image settings of plugins. The editor offers forms, sections, categories and tags as lists, a page search next to every link target and in text links, the redirect target has a page search, and the form editor picks the reply field, the condition and the option images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
320 lines
19 KiB
JavaScript
320 lines
19 KiB
JavaScript
/*
|
||
* Der Formulareditor in der Verwaltung.
|
||
*
|
||
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
|
||
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
|
||
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
|
||
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
|
||
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
|
||
*/
|
||
import { createColorControl } from './color-control.js';
|
||
import { buttonSample } from './button-design.js';
|
||
import { openMedia } from './media.js';
|
||
|
||
const root = document.querySelector('[data-form-schema]');
|
||
if (root) {
|
||
const schema = JSON.parse(root.dataset.formSchema);
|
||
const bilder = JSON.parse(root.dataset.formImages || '[]');
|
||
const defaults = {
|
||
fieldBorderColor: schema.base.color,
|
||
fieldBackgroundColor: '#ffffff',
|
||
buttonColor: schema.buttons.backgroundColor,
|
||
buttonInk: schema.buttons.color,
|
||
};
|
||
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
|
||
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
|
||
const darker = value => {
|
||
const rgb = hex(value); if (!rgb) return value;
|
||
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
|
||
};
|
||
const luminance = rgb => {
|
||
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
|
||
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||
};
|
||
const contrast = (back, front) => {
|
||
if (!back || !front) return null;
|
||
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
|
||
return (light + 0.05) / (dark + 0.05);
|
||
};
|
||
const slug = text => text.toLowerCase()
|
||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
|
||
const el = (tag, className, text) => {
|
||
const node = document.createElement(tag); node.className = className;
|
||
if (text !== undefined) node.textContent = text; return node;
|
||
};
|
||
|
||
for (const form of root.querySelectorAll('[data-form-editor]')) {
|
||
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
|
||
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
|
||
const buttonLabel = form.querySelector('[data-form-button-label]');
|
||
const colorInputs = {};
|
||
const cards = () => [...list.querySelectorAll('[data-field]')];
|
||
let next = cards().length;
|
||
|
||
const renderPreview = () => {
|
||
preview.replaceChildren();
|
||
preview.style.color = hex(schema.base.color) || '#203b34';
|
||
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
|
||
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
|
||
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
|
||
let shown = 0;
|
||
for (const card of cards()) {
|
||
const label = card.querySelector('[data-field-label]').value.trim();
|
||
if (!label) continue;
|
||
shown++;
|
||
const type = card.querySelector('[data-field-type]').value;
|
||
const help = card.querySelector('[data-field-help]').value.trim();
|
||
if (type === 'step') {
|
||
// Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung.
|
||
const step = el('div', 'form-preview-step');
|
||
step.append(el('span', 'form-preview-step-title', label));
|
||
if (help) step.append(el('span', 'form-preview-help', help));
|
||
preview.append(step);
|
||
continue;
|
||
}
|
||
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
|
||
const showIf = card.querySelector('[data-field-showif]').value.trim();
|
||
const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`);
|
||
const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean);
|
||
if (type === 'consent') {
|
||
const check = el('span', 'form-preview-check');
|
||
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
|
||
field.append(check);
|
||
} else if (type === 'radio' || type === 'checkboxes') {
|
||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||
for (const option of options()) {
|
||
const check = el('span', 'form-preview-check');
|
||
check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option));
|
||
field.append(check);
|
||
}
|
||
} else {
|
||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||
const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : '';
|
||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit));
|
||
}
|
||
if (help) field.append(el('span', 'form-preview-help', help));
|
||
if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`));
|
||
preview.append(field);
|
||
}
|
||
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
|
||
const values = { ...schema.buttons };
|
||
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
|
||
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
|
||
if (ink) { values.color = ink; values.hoverColor = ink; }
|
||
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
|
||
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
|
||
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
|
||
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
|
||
note.hidden = ratio === null;
|
||
if (ratio !== null) {
|
||
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
|
||
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
|
||
}
|
||
};
|
||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||
preview.after(note);
|
||
|
||
// Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit
|
||
// Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der
|
||
// Auswahl geschrieben; ohne Skript tippt man hinein.
|
||
const bildVon = new Map(bilder.map(bild => [bild.id, bild]));
|
||
const renderImagePickers = card => {
|
||
const area = card.querySelector('[data-field-images-input]');
|
||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||
let box = card.querySelector('[data-image-pickers]');
|
||
if (!box) {
|
||
box = el('div', 'field-image-pickers'); box.dataset.imagePickers = '';
|
||
area.hidden = true; area.after(box);
|
||
}
|
||
if (box.dataset.signature === options.join('|')) return;
|
||
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
|
||
box.dataset.signature = options.join('|');
|
||
box.replaceChildren();
|
||
const write = () => {
|
||
const map = {};
|
||
for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media;
|
||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||
renderPreview();
|
||
};
|
||
for (const option of options) {
|
||
const row = el('div', 'field-image-picker'); row.dataset.option = option;
|
||
const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48;
|
||
const name = el('span', 'field-image-picker-name');
|
||
const choose = el('button', 'secondary', ''); choose.type = 'button';
|
||
const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button';
|
||
remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`);
|
||
const show = id => {
|
||
row.dataset.media = id || '';
|
||
const bild = id ? bildVon.get(id) : null;
|
||
thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src');
|
||
name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild';
|
||
choose.textContent = id ? 'Anderes Bild' : 'Bild wählen';
|
||
choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`);
|
||
remove.hidden = !id;
|
||
};
|
||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => {
|
||
if (!item) return;
|
||
bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src });
|
||
show(item.id); write();
|
||
} }));
|
||
remove.addEventListener('click', () => { show(''); write(); choose.focus(); });
|
||
show(current[option] || '');
|
||
row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row);
|
||
}
|
||
};
|
||
|
||
/*
|
||
* Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und
|
||
* dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das
|
||
* gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben.
|
||
*/
|
||
const fieldsBefore = card => {
|
||
const list = [];
|
||
for (const other of cards()) {
|
||
if (other === card) break;
|
||
const name = other.querySelector('[data-field-name]').value.trim();
|
||
const type = other.querySelector('[data-field-type]').value;
|
||
if (name && ['select', 'radio', 'checkboxes'].includes(type)) {
|
||
list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name,
|
||
options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) });
|
||
}
|
||
}
|
||
return list;
|
||
};
|
||
const renderCondition = card => {
|
||
const fieldInput = card.querySelector('[data-field-showif]');
|
||
const valuesInput = card.querySelector('[data-field-showvalues]');
|
||
const candidates = fieldsBefore(card);
|
||
const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]);
|
||
let box = card.querySelector('[data-condition-picker]');
|
||
if (!box) {
|
||
box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = '';
|
||
fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true;
|
||
fieldInput.closest('[data-field-condition]').prepend(box);
|
||
const help = box.parentElement.querySelector(':scope > .field-help');
|
||
if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.';
|
||
}
|
||
if (box.dataset.signature === signature) return;
|
||
box.dataset.signature = signature;
|
||
const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null;
|
||
box.replaceChildren();
|
||
const id = `${fieldInput.id}-select`;
|
||
const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id;
|
||
const select = document.createElement('select'); select.id = id; select.dataset.key = 'field';
|
||
select.append(new Option('Immer zeigen', ''));
|
||
for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name));
|
||
if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value));
|
||
select.value = fieldInput.value;
|
||
select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); });
|
||
box.append(label, select);
|
||
const chosen = candidates.find(c => c.name === fieldInput.value);
|
||
if (chosen) {
|
||
const set = document.createElement('fieldset'); set.className = 'field-condition-values';
|
||
set.append(el('legend', '', '… eine dieser Antworten gewählt ist'));
|
||
const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean);
|
||
chosen.options.forEach((option, index) => {
|
||
const check = el('label', 'check-label');
|
||
const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option);
|
||
box2.dataset.key = `value-${index}`;
|
||
box2.addEventListener('change', () => {
|
||
valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', ');
|
||
refresh();
|
||
});
|
||
check.append(box2, document.createTextNode(` ${option}`)); set.append(check);
|
||
});
|
||
box.append(set);
|
||
}
|
||
if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus();
|
||
};
|
||
const reply = form.querySelector('[name="replyToField"]');
|
||
const renderReply = () => {
|
||
if (!reply) return;
|
||
const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value }))
|
||
.filter(field => field.name && field.type === 'email');
|
||
let select = form.querySelector('[data-reply-picker]');
|
||
if (!select) {
|
||
select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id;
|
||
reply.id += '-value'; reply.type = 'hidden'; reply.after(select);
|
||
select.addEventListener('change', () => { reply.value = select.value; });
|
||
}
|
||
const signature = JSON.stringify([emails, reply.value]);
|
||
if (select.dataset.signature === signature) return;
|
||
select.dataset.signature = signature;
|
||
select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', ''));
|
||
for (const field of emails) select.append(new Option(field.label || field.name, field.name));
|
||
if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value));
|
||
select.value = reply.value;
|
||
const help = reply.parentElement.querySelector('.field-help');
|
||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||
};
|
||
|
||
const refresh = () => {
|
||
cards().forEach((card, index) => {
|
||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||
const type = card.querySelector('[data-field-type]').value;
|
||
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
|
||
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
|
||
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
|
||
renderCondition(card);
|
||
card.querySelector('[data-field-number]').hidden = type !== 'number';
|
||
const remove = card.querySelector('[data-remove-field]');
|
||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||
const required = card.querySelector('[data-field-required]');
|
||
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
|
||
if (type === 'consent') { required.checked = true; required.disabled = true; }
|
||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||
else required.disabled = false;
|
||
});
|
||
renderReply();
|
||
renderPreview();
|
||
};
|
||
|
||
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
|
||
// kommt jedes weitere über den Knopf.
|
||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||
add.hidden = false;
|
||
add.addEventListener('click', () => {
|
||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||
refresh(); card.querySelector('[data-field-label]').focus();
|
||
});
|
||
list.addEventListener('click', event => {
|
||
const button = event.target.closest('[data-remove-field]');
|
||
if (!button) return;
|
||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||
});
|
||
list.addEventListener('input', event => {
|
||
const card = event.target.closest('[data-field]');
|
||
if (event.target.matches('[data-field-label]')) {
|
||
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
|
||
const name = card.querySelector('[data-field-name]');
|
||
if (!name.dataset.touched) name.value = slug(event.target.value);
|
||
}
|
||
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
|
||
refresh();
|
||
});
|
||
list.addEventListener('change', refresh);
|
||
for (const card of cards()) {
|
||
const name = card.querySelector('[data-field-name]');
|
||
if (name.value) name.dataset.touched = '1';
|
||
}
|
||
|
||
for (const input of form.querySelectorAll('[data-form-color]')) {
|
||
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
|
||
const id = input.id; input.type = 'hidden'; input.id += '-value';
|
||
colorInputs[key] = input;
|
||
const control = createColorControl({
|
||
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
|
||
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
|
||
});
|
||
control.querySelector('.color-inherit').textContent = 'Wie die Website';
|
||
input.after(control);
|
||
}
|
||
buttonLabel.addEventListener('input', renderPreview);
|
||
refresh();
|
||
}
|
||
}
|