Pick images, pages, forms, sections and terms instead of typing ids and addresses
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>
This commit is contained in:
1 parent
31fb53c1fc
commit
711af312af
19 files changed
+446
-43
No files matched your search
+15
-2
@@ -898,8 +898,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker select { margin: 0 }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
|
||||
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
|
||||
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
|
||||
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
|
||||
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
|
||||
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
@@ -977,3 +983,10 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
|
||||
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
|
||||
.media-field { min-width: 0 }
|
||||
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
|
||||
.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb }
|
||||
.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px }
|
||||
+31
-9
@@ -1,5 +1,6 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -330,13 +331,19 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') {
|
||||
if (key === 'href' || options.link) {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
// Eine eigene Seite sucht man, statt ihren Pfad zu kennen.
|
||||
const choose = value => { input.value = value; applyValue(); history.mergeKey = null; };
|
||||
const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen';
|
||||
pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } }));
|
||||
container.append(pdf);
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf);
|
||||
container.append(row);
|
||||
// Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest.
|
||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
if (!block) {
|
||||
@@ -346,6 +353,21 @@ function renderContent(block, panel) {
|
||||
}
|
||||
const plugin = pluginDefinition(block);
|
||||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||||
/*
|
||||
* Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft.
|
||||
* Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu
|
||||
* verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld.
|
||||
*/
|
||||
const pick = (kind, current, empty = null) => {
|
||||
const list = designSchema?.choices?.[kind];
|
||||
if (!list) return {};
|
||||
const choices = {};
|
||||
if (empty !== null) choices[''] = empty;
|
||||
else if (!current) choices[''] = 'Bitte wählen …';
|
||||
Object.assign(choices, list);
|
||||
if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`;
|
||||
return { choices };
|
||||
};
|
||||
if (block.type === 'text') {
|
||||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||||
@@ -410,7 +432,7 @@ function renderContent(block, panel) {
|
||||
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||||
}
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||
if (block.type === 'icon') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||
@@ -490,7 +512,7 @@ function renderContent(block, panel) {
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
@@ -526,8 +548,8 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
@@ -774,7 +796,7 @@ function renderPluginFields(block, panel, plugin, field) {
|
||||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength });
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+121
-15
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
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) {
|
||||
@@ -115,8 +116,10 @@ if (root) {
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
// Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das
|
||||
// gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein.
|
||||
// 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);
|
||||
@@ -129,23 +132,124 @@ if (root) {
|
||||
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('label', 'field-image-picker');
|
||||
row.append(el('span', 'field-image-picker-option', option));
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Kein Bild', ''));
|
||||
for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id));
|
||||
select.dataset.option = option;
|
||||
select.addEventListener('change', () => {
|
||||
const map = {};
|
||||
for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
});
|
||||
row.append(select); box.append(row);
|
||||
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);
|
||||
@@ -153,6 +257,7 @@ if (root) {
|
||||
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`);
|
||||
@@ -162,6 +267,7 @@ if (root) {
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderReply();
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
|
||||
@@ -37,4 +37,8 @@
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-document{margin:6px 0 4px}
|
||||
.href-pickers{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
|
||||
.href-document{margin:0}
|
||||
/* Seite als Linkziel wählen: schmal, die Trefferliste darf überstehen. */
|
||||
.page-dialog{max-width:560px;min-height:420px;overflow:visible}
|
||||
.page-dialog .content-picker{margin-top:16px}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* Bildfelder in Formularen der Verwaltung: Vorschaubild beim Teilen, Beitragsbild.
|
||||
*
|
||||
* Über den Dokumentknoten statt je Feld, weil die Suche in der Inhaltsliste die Ergebnisse
|
||||
* austauscht - ein Feld, das danach erscheint, funktioniert trotzdem.
|
||||
*/
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-media-choose], [data-media-remove]');
|
||||
const field = button?.closest('[data-media-field]');
|
||||
if (!field) return;
|
||||
const input = field.querySelector('input[type="hidden"]');
|
||||
const thumb = field.querySelector('.media-field-thumb');
|
||||
const name = field.querySelector('.media-field-name');
|
||||
const choose = field.querySelector('[data-media-choose]');
|
||||
const remove = field.querySelector('[data-media-remove]');
|
||||
const show = item => {
|
||||
input.value = item?.id ?? '';
|
||||
thumb.hidden = !item;
|
||||
if (item) thumb.src = item.variants[0].src; else thumb.removeAttribute('src');
|
||||
name.textContent = item ? item.title : 'Kein Bild gewählt';
|
||||
choose.textContent = item ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !item;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
if (button.matches('[data-media-remove]')) { show(null); choose.focus(); return; }
|
||||
// Kein Alternativtext nötig: das Bild erscheint als Vorschau beim Teilen oder in einer Karte,
|
||||
// deren Titel schon sagt, worum es geht.
|
||||
openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) show(item); } });
|
||||
});
|
||||
@@ -52,7 +52,11 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
};
|
||||
input.addEventListener('focus', () => search(''));
|
||||
input.addEventListener('input', () => {
|
||||
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
// Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite
|
||||
// ist; gewählt wird trotzdem am bequemsten aus der Suche.
|
||||
const free = picker.dataset.free === '1';
|
||||
value.value = free ? input.value.trim().replace(/^\/+/, '') : '';
|
||||
input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
value.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
@@ -66,3 +70,30 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
}
|
||||
|
||||
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
|
||||
/**
|
||||
* Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben
|
||||
* Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad.
|
||||
*/
|
||||
export function openPageDialog({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'page-dialog-title');
|
||||
const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices';
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
|
||||
<div class="content-picker">
|
||||
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
|
||||
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
|
||||
<input type="hidden" class="picker-value">
|
||||
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>`;
|
||||
const picker = dialog.querySelector('.content-picker');
|
||||
picker.dataset.source = source; picker.classList.add('menu-add');
|
||||
document.body.append(dialog);
|
||||
setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } });
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
dialog.showModal();
|
||||
dialog.querySelector('#page-dialog-search').focus();
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* the alternative would be a selection engine of our own. The formatting it produces is normalised
|
||||
* on the server anyway, so a browser quirk cannot store something unexpected.
|
||||
*/
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */
|
||||
export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a'];
|
||||
@@ -116,7 +118,12 @@ export function createRichText(value, apply, { label = 'Text', plain = false } =
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
// Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen.
|
||||
const fill = value => { linkInput.value = value; linkInput.focus(); };
|
||||
linkRow.append(linkInput,
|
||||
button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'),
|
||||
button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'),
|
||||
newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
Reference in new issue
Block a user