feat: add new-tab links and removable media previews (1.7.3)
This commit is contained in:
1 parent
b8991ddd5c
commit
6d21c452bd
28 files changed
+496
-46
No files matched your search
@@ -36,6 +36,8 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
||||
|
||||
**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`.
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
|
||||
|
||||
Schema v20 ergänzt optionales `props.newTab: boolean` an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer `target="_blank" rel="noopener"`. Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich `_blank` als Ziel und setzt `noopener` selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
|
||||
|
||||
Bilder, Galerie-/Logobilder und Hintergründe zeigen kleine Vorschaubilder mit Lade-/Fehlerzustand. Für Hintergrund-IDs liefert eine authentifizierte einzelne Medienabfrage die tatsächlich vorhandene Bildvariante; auch kleine Originale und archivierte, weiterhin verwendete Dateien funktionieren. Die Bildauswahl verwendet denselben Vorschaucache. Späte Antworten ersetzen keine neuere Auswahl. Entfernen eines Inhaltsbildes löscht dessen Block aus dem Entwurf, ein Galerieeintrag bleibt eine einzelne Änderung; beide sind rückgängig zu machen. Die Mediendatei bleibt erhalten. Bildaustausch ersetzt Quelle und Bildmetadaten, erhält Link, Bildunterschrift, Block-ID und Gestaltung.
|
||||
|
||||
Ein Hintergrund wird mit `style.<device>.backgroundImage: "none"` ausdrücklich ausgeblendet. Das ist von Zurücksetzen zu unterscheiden: Zurücksetzen entfernt den eigenen Wert und stellt Vererbung her. Der Compiler übernimmt Bild, Verlauf und Überlagerung schrittweise Desktop→Tablet→Mobil; dadurch erscheint ein auf Tablet entferntes Bild nicht auf Mobil wieder. Geerbte Verläufe bleiben beim Entfernen des Bildes erhalten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Erzeugung. Die neuen Bedienelemente gehören nur zum Editor; kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Migration:** Dokumente v1–19 bleiben lesbar und werden nicht beim Laden umgeschrieben. Das Editor-Modell hebt die Version erst beim Setzen eines neuen Block-Linkwerts oder eines ausdrücklichen Hintergrund-„none“ auf v20 an, einschließlich der bisherigen Schema-Upgrades. Die Validierung verlangt für diese Werte v20. Keine Datenbankmigration. Rückweg auf ältere Software: vor Bearbeitung mit v20 angelegte Revision/Export oder die Sicherung vor dem Update wiederherstellen; ältere Validatoren können v20 nicht speichern. Menüs benötigen ebenfalls die Sicherung, falls neue Tab-Einstellungen auf älterem Code erhalten bleiben sollen. Kein stilles Herabsetzen der Dokumentversion.
|
||||
|
||||
## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026)
|
||||
|
||||
Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style.<device>.width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.7.3 – Links in neuen Tabs und Bildvorschauen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** „In neuem Tab öffnen“ bei Buttons, verlinkten Bildern, Symbolen, Symbolkarten, Aufruf-Bändern, Textlinks und Menüeinträgen. Serverseitig validierte Ausgabe mit `target="_blank" rel="noopener"`; normale Links bleiben im selben Tab. Textlinks lassen sich mit vorhandenem Ziel/Häkchen wieder bearbeiten. „Nichts tun“ am Bild deaktiviert auch eine früher hinterlegte Verlinkung. Inhalts-, Galerie-/Logo- und Hintergrundbilder zeigen Vorschauen mit Lade-/Fehlerzustand und Entfernen-Funktion. Entfernen ist rückgängig zu machen und erhält die Datei in der Mediathek. Bildaustausch erhält Link, Bildunterschrift und Gestaltung. Geerbte Hintergrundbilder sind sichtbar und je Gerät entfernbar; kleinere Geräte übernehmen die Entfernung, sofern dort kein eigener Wert besteht. Verläufe bleiben erhalten. Tastaturfokus bleibt beim Umschalten der Klickaktion und nach dem Entfernen eines Hintergrunds bedienbar.
|
||||
|
||||
**Datenvertrag:** Dokument-Schema v20 nur beim Verwenden neuer Block-Linkwerte oder ausdrücklicher Hintergrundentfernung. Vorhandene Dokumente bleiben lesbar, keine Datenbankmigration. Migration und Rückweg: ADR 081. Medienvorschauen verwenden eine authentifizierte Einzelabfrage und tatsächlich vorhandene kleine Varianten, auch bei archivierten verwendeten Bildern. Kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Geprüft:** 338 PHP-Tests (`CmsTest|EditorSecurityTest`) mit 2873 Assertions; 32 JavaScript-Tests; 47 Twig-Vorlagen gültig; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:link-media` auf einer isolierten Kant-IT-Kopie: Vorschauen, fehlendes Bild, Entfernen/Undo bei Bild/Galerie/Hintergrund, Vererbung/Reset, Links erstellen/ändern/deaktivieren, Menü-Undo, Speichern/Laden und Veröffentlichung. Neuer Tab tatsächlich geöffnet, Ursprungstab erhalten und `window.opener` null. Veröffentlichter Hintergrund ohne JavaScript bei 1600/900/390 px geprüft. Öffentliche Seite sowie Desktop-/Mobil-Editor ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). Vollständiges `npm run test:editor` ebenfalls erfolgreich, einschließlich Mediathek, Entwurf/Veröffentlichung/Konflikten und Desktop-/Mobil-Barrierefreiheit. Originaldaten bleiben unverändert; Schreibtests ausschließlich in temporären Kopien.
|
||||
|
||||
**Veröffentlichung:** Release-Paket 1.7.3 wird aus dem geprüften Commit gebaut und anschließend auf dem bestehenden Git bereitgestellt.
|
||||
|
||||
**Grenzen und nächster Schritt:** Keine erneute Lighthouse-Messung für diese Link-/Mediensteuerung; öffentliche Ausgabe und Barrierefreiheit direkt geprüft. Keine neuen Firefox-/Safari-/Screenreader-Prüfungen. Nach Veröffentlichung kann das Update über die Systemverwaltung eingespielt werden; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
## 1.7.2 – Bildbreite direkt in Prozent einstellen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Beim ausgewählten Bild steht im Reiter Inhalt oben „Bildbreite (%)“ mit Regler, Zahlenfeld und Zurücksetzen. 1–100 % beziehen sich auf die verfügbare Breite der Sektion/Spalte. Desktop, Tablet und Mobil verwenden die vorhandene Vererbung; Herkunft und bestehende maximale Breiten sind sichtbar. Beide Bildsteuerungen bearbeiten denselben gespeicherten Breitenwert wie Design → Größe. Originalmaße, Medienvarianten, Alt-Text und Bildunterschrift bleiben erhalten. Ungültige Zahlen verändern den gespeicherten Wert nicht, eine Ziehbewegung bleibt ein Undo-Schritt. Der veraltete Hinweis auf eine erst künftig verfügbare Mediathek wurde berichtigt. Keine neue Schema-/Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen Paket.
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"audit": "node scripts/audit.mjs",
|
||||
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs"
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -708,6 +708,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
||||
.selected-media-preview{margin:12px 0;padding:10px;border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.selected-media-preview img{display:block;width:100%;height:140px;object-fit:contain;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px}.selected-media-preview figcaption{font-size:12px;line-height:1.5;overflow-wrap:anywhere;margin-top:8px;color:var(--muted)}
|
||||
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
|
||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -444,15 +444,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
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');
|
||||
const hasImage = Boolean(value && value !== 'none');
|
||||
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
|
||||
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
choose.type = 'button';
|
||||
choose.id = `style-${key}`; choose.setAttribute('aria-label', choose.textContent);
|
||||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||||
wrap.append(status, choose);
|
||||
if (value) {
|
||||
wrap.append(preview.element, choose);
|
||||
if (hasImage) {
|
||||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||||
remove.type = 'button';
|
||||
remove.addEventListener('click', () => apply(''));
|
||||
remove.title = 'Hintergrundbild für dieses Gerät entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { apply('none'); document.getElementById(choose.id)?.focus(); });
|
||||
wrap.append(remove);
|
||||
}
|
||||
return wrap;
|
||||
@@ -541,7 +544,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
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 if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
@@ -618,6 +621,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
};
|
||||
|
||||
function describe(property, value) {
|
||||
if (property.type === 'media') return value === 'none' ? 'Kein Bild' : 'Bild aus der Mediathek';
|
||||
if (property.type === 'choice') return property.values[value] ?? value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 19;
|
||||
export const SCHEMA_VERSION = 20;
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
@@ -310,7 +310,7 @@ export function mediaProps(media) {
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
||||
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
@@ -496,6 +496,7 @@ export function setStyle(document, id, device, key, value, { side = null, hover
|
||||
if (!block) return;
|
||||
const empty = value === '' || value === null || value === undefined;
|
||||
if (!empty) upgradeDocument(document, 10);
|
||||
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
|
||||
|
||||
block.style ??= {};
|
||||
block.style[device] ??= {};
|
||||
|
||||
@@ -193,6 +193,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
||||
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||
.rt-link input{font-size:11px;min-height:30px}
|
||||
.rt-link{flex-wrap:wrap;align-items:center}.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
|
||||
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
||||
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||
|
||||
+27
-2
@@ -1,5 +1,5 @@
|
||||
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 } from './media.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
@@ -177,6 +177,7 @@ function renderProperties() {
|
||||
}
|
||||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||
let imagePreview;
|
||||
const field = (label, key, value, options = {}) => {
|
||||
const id = `property-${key}`;
|
||||
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||||
@@ -203,14 +204,17 @@ function renderProperties() {
|
||||
else current[key] = next;
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, `${selected || 'page'}:${key}`);
|
||||
if (key === 'decorative') renderProperties();
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
if (['decorative', 'click'].includes(key)) { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); }
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) panel.append(labelEl); else panel.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); panel.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true });
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
@@ -334,10 +338,15 @@ function renderProperties() {
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'image') {
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
panel.append(imagePreview.element);
|
||||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
||||
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen';
|
||||
remove.title = 'Bild aus diesem Inhalt entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { actions.delete(block.id); $('#undo').focus(); }); panel.append(remove);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Lokale Datei oder HTTPS-Adresse. Über die Mediathek wird die passende Bildvariante automatisch gewählt.' });
|
||||
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||||
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||||
@@ -351,6 +360,22 @@ function renderProperties() {
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
}
|
||||
if (['gallery', 'logos'].includes(block.type)) {
|
||||
const images = document.createElement('div'); images.className = 'selected-media-list';
|
||||
for (const child of block.children || []) {
|
||||
const item = document.createElement('div');
|
||||
const title = child.props.alt || child.props.caption || 'Bild';
|
||||
item.append(createMediaPreview({ src: child.props.sources?.[0]?.src || child.props.src, label: title }).element);
|
||||
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'text-button'; edit.textContent = 'Bearbeiten'; edit.setAttribute('aria-label', `${title} bearbeiten`);
|
||||
edit.addEventListener('click', () => choose(child.id));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Entfernen'; remove.setAttribute('aria-label', `${title} entfernen`);
|
||||
remove.addEventListener('click', () => {
|
||||
history.change(doc => { const found = locate(doc.blocks, child.id); if (found) found.siblings.splice(found.index, 1); }); changed(); $('#undo').focus();
|
||||
});
|
||||
item.append(edit, remove); images.append(item);
|
||||
}
|
||||
panel.append(images);
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
||||
|
||||
const previews = new Map();
|
||||
async function previewItem(id) {
|
||||
if (!previews.has(id)) previews.set(id, fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media/' + id, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('Bild nicht erreichbar');
|
||||
return (await response.json()).item;
|
||||
}).catch(error => { previews.delete(id); throw error; }));
|
||||
return previews.get(id);
|
||||
}
|
||||
|
||||
/** A preview of the current selection, including small originals without a 480 px variant. */
|
||||
export function createMediaPreview(initial = {}) {
|
||||
const wrap = element('figure', '', 'selected-media-preview');
|
||||
const image = element('img'); image.alt = ''; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; image.hidden = true;
|
||||
const caption = element('figcaption'); caption.setAttribute('aria-live', 'polite');
|
||||
wrap.append(image, caption); let sequence = 0;
|
||||
const update = async ({ src = '', mediaId = '', label = '' } = {}) => {
|
||||
const request = ++sequence; image.hidden = true; image.removeAttribute('src');
|
||||
caption.textContent = src || mediaId ? 'Bildvorschau wird geladen …' : 'Kein Bild ausgewählt';
|
||||
if (!src && !mediaId) return;
|
||||
try {
|
||||
if (!src && /^[a-f0-9]{32}$/.test(mediaId)) {
|
||||
const item = await previewItem(mediaId);
|
||||
if (request !== sequence) return;
|
||||
src = item.variants[0]?.src || ''; label ||= item.title;
|
||||
}
|
||||
const url = new URL(src, location.origin);
|
||||
if (!(src.startsWith('/') && !src.startsWith('//') && url.origin === location.origin) && url.protocol !== 'https:') throw new Error();
|
||||
if (!src) throw new Error();
|
||||
const description = label || decodeURIComponent(url.pathname.split('/').pop()) || 'Ausgewähltes Bild';
|
||||
image.onload = () => { if (request === sequence) { image.hidden = false; caption.textContent = description; } };
|
||||
image.onerror = () => { if (request === sequence) { image.hidden = true; caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.'; } };
|
||||
image.src = url.href;
|
||||
} catch {
|
||||
if (request === sequence) caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.';
|
||||
}
|
||||
};
|
||||
update(initial);
|
||||
return { element: wrap, update };
|
||||
}
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
@@ -24,6 +64,7 @@ export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
|
||||
for (const item of data.items || (data.item ? [data.item] : [])) previews.set(item.id, Promise.resolve(item));
|
||||
return data;
|
||||
}
|
||||
function render() {
|
||||
|
||||
@@ -9,7 +9,7 @@ let serial = list.children.length, pendingPage = null, busy = false;
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
||||
const rows = () => [...list.children].map(row => {
|
||||
const data = { level: Number(row.dataset.level) };
|
||||
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.value; });
|
||||
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; });
|
||||
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
|
||||
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
|
||||
return data;
|
||||
@@ -49,7 +49,10 @@ function createRow(data) {
|
||||
const field = (key, value, label, type = 'text') => {
|
||||
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
|
||||
input.id = `menu-${key}-${id}`;
|
||||
if (type !== 'hidden') { const caption = el('label', '', label); caption.htmlFor = input.id; body.append(caption, input); }
|
||||
if (type !== 'hidden') {
|
||||
const caption = el('label', type === 'checkbox' ? 'check-label' : '', label); caption.htmlFor = input.id;
|
||||
if (type === 'checkbox') { caption.prepend(input); body.append(caption); } else body.append(caption, input);
|
||||
}
|
||||
else row.append(input);
|
||||
return input;
|
||||
};
|
||||
@@ -68,6 +71,7 @@ function createRow(data) {
|
||||
body.append(picker); setupPicker(picker);
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
||||
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
||||
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
||||
@@ -101,7 +105,8 @@ form.querySelector('#menu-add-page').addEventListener('click', () => {
|
||||
form.querySelector('#menu-add-url').addEventListener('click', () => {
|
||||
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
|
||||
if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; }
|
||||
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), level: 1 }); name.value = url.value = '';
|
||||
const newTab = form.querySelector('#link-new-tab');
|
||||
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), newTab: newTab.checked, level: 1 }); name.value = url.value = ''; newTab.checked = false;
|
||||
});
|
||||
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
|
||||
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
|
||||
|
||||
@@ -83,7 +83,10 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
linkInput.type = 'text';
|
||||
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
|
||||
linkInput.setAttribute('aria-label', 'Linkziel');
|
||||
let savedRange = null;
|
||||
const newTabLabel = document.createElement('label'); newTabLabel.className = 'check-label';
|
||||
const newTab = document.createElement('input'); newTab.type = 'checkbox';
|
||||
newTabLabel.append(newTab, document.createTextNode(' In neuem Tab öffnen'));
|
||||
let savedRange = null, currentLink = null;
|
||||
const confirmLink = () => {
|
||||
const target = linkInput.value.trim();
|
||||
linkRow.hidden = true;
|
||||
@@ -93,8 +96,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(savedRange);
|
||||
}
|
||||
if (target) document.execCommand('createLink', false, target);
|
||||
else document.execCommand('unlink');
|
||||
if (target) {
|
||||
if (currentLink && surface.contains(currentLink)) currentLink.setAttribute('href', target);
|
||||
else document.execCommand('createLink', false, target);
|
||||
const range = window.getSelection().rangeCount ? window.getSelection().getRangeAt(0) : null;
|
||||
for (const anchor of surface.querySelectorAll('a')) {
|
||||
if (anchor !== currentLink && !(anchor.getAttribute('href') === target && range?.intersectsNode(anchor))) continue;
|
||||
if (newTab.checked) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener'); }
|
||||
else { anchor.removeAttribute('target'); anchor.removeAttribute('rel'); }
|
||||
}
|
||||
} else document.execCommand('unlink');
|
||||
push(surface.innerHTML);
|
||||
linkInput.value = '';
|
||||
};
|
||||
@@ -102,14 +113,20 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
savedRange = selection.rangeCount > 0 && surface.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0).cloneRange() : null;
|
||||
const node = savedRange?.startContainer;
|
||||
currentLink = (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement)?.closest('a');
|
||||
if (currentLink && (!surface.contains(currentLink) || !currentLink.contains(savedRange.endContainer))) currentLink = null;
|
||||
linkInput.value = currentLink?.getAttribute('href') || '';
|
||||
newTab.checked = currentLink?.getAttribute('target') === '_blank';
|
||||
linkRow.hidden = false;
|
||||
linkInput.focus();
|
||||
}));
|
||||
linkRow.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); linkRow.hidden = true; surface.focus(); } });
|
||||
|
||||
const toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
|
||||
const toSource = source.hidden;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/link-media'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
|
||||
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
data.document.blocks.unshift({ id: 'qa-link-section', type: 'section', children: [
|
||||
{ id: 'qa-link-image', type: 'image', props: { ...original, href: '/impressum', click: 'link' } },
|
||||
{ id: 'qa-link-button', type: 'button', props: { text: 'QA Neuer Tab', href: '/impressum' } },
|
||||
{ id: 'qa-link-text', type: 'text', props: { text: '<p>Textlink prüfen</p>' } },
|
||||
{ id: 'qa-link-gallery', type: 'gallery', children: [{ id: 'qa-gallery-image', type: 'image', props: original }] },
|
||||
] });
|
||||
data.document.blocks.unshift({ id: 'qa-background', type: 'section', style: { desktop: { backgroundImage: chosen.id, minHeight: '200px' } }, children: [] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const frame = page.frameLocator('#preview');
|
||||
const select = async (id, panel = 'content') => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`[data-panel="${panel}"]`).click(); };
|
||||
const thumbnail = () => page.locator('#properties > .selected-media-preview img');
|
||||
const background = async hasImage => page.waitForFunction(hasImage => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, block = doc?.getElementById('qa-background');
|
||||
return block && doc.defaultView.getComputedStyle(block).backgroundImage.includes('url(') === hasImage;
|
||||
}, hasImage);
|
||||
const save = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
|
||||
await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
|
||||
assert.ok(await thumbnail().evaluate(img => img.naturalWidth > 0));
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await frame.locator('#qa-link-image a[target="_blank"][rel="noopener"]').waitFor();
|
||||
await page.getByLabel('Beim Klick', { exact: true }).selectOption('keine');
|
||||
await frame.locator('#qa-link-image a').waitFor({ state: 'detached' });
|
||||
await page.getByLabel('Beim Klick', { exact: true }).selectOption('link');
|
||||
assert.equal(await page.getByLabel('Beim Klick', { exact: true }).evaluate(el => el === document.activeElement), true);
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await page.getByLabel('Bildadresse', { exact: true }).fill('/nicht-vorhanden.png');
|
||||
await page.getByLabel('Bildadresse', { exact: true }).blur();
|
||||
await page.locator('#properties > .selected-media-preview').getByText(/nicht verfügbar/).waitFor();
|
||||
await page.locator('#undo').click(); await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.getByRole('button', { name: 'Bild entfernen', exact: true }).click();
|
||||
await frame.locator('#qa-link-image').waitFor({ state: 'detached' });
|
||||
assert.equal((await context.request.get(origin + '/admin/api/media/' + chosen.id)).status(), 200);
|
||||
await page.locator('#undo').click(); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.locator('.right-panel').screenshot({ path: `${report}/image-inspector.png` });
|
||||
await select('qa-link-gallery'); await page.locator('.selected-media-list img').waitFor({ state: 'visible' });
|
||||
await page.locator('.selected-media-list').getByRole('button', { name: /entfernen$/ }).click();
|
||||
await frame.locator('#qa-gallery-image').waitFor({ state: 'detached' });
|
||||
await page.locator('#undo').click(); await frame.locator('#qa-gallery-image').waitFor();
|
||||
checks.push('image and gallery thumbnails, missing-image feedback, remove/undo without deleting media; image link/no-action toggle');
|
||||
|
||||
await select('qa-background', 'design');
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Hintergrund$/ }) });
|
||||
if (!(await group.getAttribute('open'))) await group.locator('summary').click();
|
||||
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' });
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' }); await background(true);
|
||||
await group.getByRole('button', { name: 'Bild entfernen', exact: true }).click(); await background(false);
|
||||
assert.equal(await page.locator('#style-backgroundImage').evaluate(el => el === document.activeElement), true);
|
||||
await page.locator('[data-viewport="mobile"]').click(); await background(false);
|
||||
await page.locator('[data-viewport="desktop"]').click(); await background(true);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Hintergrundbild zurücksetzen', exact: true }).click(); await background(true);
|
||||
await page.locator('#undo').click(); await background(false);
|
||||
await page.locator('[data-viewport="desktop"]').click();
|
||||
checks.push('inherited background thumbnail, remove on tablet/mobile, preserve desktop, reset and undo');
|
||||
|
||||
await select('qa-link-button'); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await frame.locator('#qa-link-button[target="_blank"]').waitFor();
|
||||
await select('qa-link-text');
|
||||
const surface = page.locator('.rt-surface');
|
||||
await surface.evaluate(el => { const range = document.createRange(); range.selectNodeContents(el.querySelector('p')); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.focus(); });
|
||||
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
|
||||
await page.getByLabel('Linkziel', { exact: true }).fill('/impressum');
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await frame.locator('#qa-link-text a[target="_blank"][rel="noopener"]').waitFor();
|
||||
const editExisting = async () => {
|
||||
await surface.locator('a').evaluate(el => { const range = document.createRange(); range.selectNodeContents(el); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.closest('[contenteditable]').focus(); });
|
||||
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/impressum');
|
||||
};
|
||||
await editExisting(); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).uncheck(); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await frame.locator('#qa-link-text a:not([target])').waitFor();
|
||||
await editExisting(); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await page.locator('.rt-link').screenshot({ path: `${report}/text-link.png` });
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await save(); await page.reload(); await select('qa-link-image');
|
||||
assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
checks.push('button and rich-text target creation/edit/uncheck, safe attributes, save/reload and publication');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
|
||||
await page.locator('#link-label').fill('QA Menülink'); await page.locator('#link-url').fill('/impressum');
|
||||
await page.locator('#link-new-tab').check(); await page.locator('#menu-add-url').click();
|
||||
const entry = () => page.locator('#menu-items > li').last();
|
||||
await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await entry().getByLabel('In neuem Tab öffnen', { exact: true }).uncheck();
|
||||
await page.locator('#menu-undo').click();
|
||||
await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload(); await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await entry().screenshot({ path: `${report}/menu-link.png` });
|
||||
checks.push('menu creation, checkbox undo, save and reload');
|
||||
|
||||
const publicPage = await context.newPage(); await publicPage.goto(origin);
|
||||
for (const selector of ['#qa-link-image a', '#qa-link-button', '#qa-link-text a']) {
|
||||
assert.equal(await publicPage.locator(selector).getAttribute('target'), '_blank');
|
||||
assert.equal(await publicPage.locator(selector).getAttribute('rel'), 'noopener');
|
||||
}
|
||||
assert.equal(await publicPage.getByRole('link', { name: 'QA Menülink', exact: true }).getAttribute('target'), '_blank');
|
||||
const popupPromise = publicPage.waitForEvent('popup'); await publicPage.locator('#qa-link-button').click();
|
||||
const popup = await popupPromise; await popup.waitForLoadState();
|
||||
assert.equal(new URL(popup.url()).pathname, '/impressum'); assert.equal(await popup.evaluate(() => window.opener), null); await popup.close();
|
||||
assert.equal(new URL(publicPage.url()).pathname, '/');
|
||||
const publicAudit = await new AxeBuilder({ page: publicPage }).analyze();
|
||||
assert.deepEqual(publicAudit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
await publicPage.close();
|
||||
const noScriptContext = await browser.newContext({ javaScriptEnabled: false });
|
||||
const noScript = await noScriptContext.newPage();
|
||||
for (const [width, hasImage] of [[1600, true], [900, false], [390, false]]) {
|
||||
await noScript.setViewportSize({ width, height: 1000 }); await noScript.goto(origin);
|
||||
assert.equal(await noScript.locator('#qa-background').evaluate(el => getComputedStyle(el).backgroundImage.includes('url(')), hasImage);
|
||||
assert.equal(await noScript.locator('#qa-link-image a').getAttribute('target'), '_blank');
|
||||
}
|
||||
await noScriptContext.close();
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await select('qa-link-image');
|
||||
for (const viewport of [1600, 390]) {
|
||||
await page.setViewportSize({ width: viewport, height: 1050 }); await thumbnail().scrollIntoViewIfNeeded();
|
||||
const result = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.locator('.selected-media-preview').evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('#properties').screenshot({ path: `${report}/image-properties-${viewport}.png` });
|
||||
}
|
||||
checks.push('public links really open a separate tab without opener; public page and desktop/mobile image controls pass axe; background removal persists without JavaScript at 1600/900/390 px');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Link and media checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -42,7 +42,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -124,6 +124,15 @@ final class DocumentValidator
|
||||
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
|
||||
$this->text($props['text'] ?? null, $limit);
|
||||
}
|
||||
if (!$plugin && array_key_exists('newTab', $props)) {
|
||||
$this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
|
||||
$this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
foreach (($block['style'] ?? []) as $values) {
|
||||
if ('none' === ($values['backgroundImage'] ?? null)) {
|
||||
$this->require($schemaVersion >= 20, 'Ein entferntes Hintergrundbild braucht Schema v20.');
|
||||
}
|
||||
}
|
||||
if ('heading' === $type) {
|
||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||
$headings += 1 === $props['level'] ? 1 : 0;
|
||||
|
||||
@@ -132,7 +132,7 @@ final class MenuRepository
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children];
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])];
|
||||
}
|
||||
|
||||
return $links;
|
||||
@@ -153,6 +153,9 @@ final class MenuRepository
|
||||
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
|
||||
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
|
||||
}
|
||||
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
||||
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
if ('page' === ($item['type'] ?? null)) {
|
||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
||||
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
|
||||
|
||||
@@ -21,7 +21,7 @@ final class RichText
|
||||
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
|
||||
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
|
||||
'ul' => [], 'ol' => [], 'li' => [],
|
||||
'a' => ['href'],
|
||||
'a' => ['href', 'target'],
|
||||
];
|
||||
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
|
||||
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
|
||||
@@ -121,7 +121,10 @@ final class RichText
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('href', $href);
|
||||
if (str_starts_with($href, 'https://')) {
|
||||
if ('_blank' !== $child->getAttribute('target')) {
|
||||
$child->removeAttribute('target');
|
||||
}
|
||||
if ('_blank' === $child->getAttribute('target') || str_starts_with($href, 'https://')) {
|
||||
$child->setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ final class StyleCompiler
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
'media' => $this->require(1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
default => $this->require(false, 'Unknown style type.'),
|
||||
};
|
||||
}
|
||||
@@ -233,7 +233,7 @@ final class StyleCompiler
|
||||
$this->css(['type' => 'color'], (string) $values['gradientTo']),
|
||||
);
|
||||
}
|
||||
if (isset($values['backgroundImage']) && null !== $this->media) {
|
||||
if (isset($values['backgroundImage']) && 'none' !== $values['backgroundImage'] && null !== $this->media) {
|
||||
$item = $this->media->find((string) $values['backgroundImage']);
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||
@@ -244,7 +244,7 @@ final class StyleCompiler
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $layers ? '' : 'background-image:'.implode(',', $layers);
|
||||
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
@@ -348,21 +348,19 @@ final class StyleCompiler
|
||||
*/
|
||||
private function scaleBackground(array $style): array
|
||||
{
|
||||
$image = $style['desktop']['backgroundImage'] ?? null;
|
||||
if (null === $image) {
|
||||
return $style;
|
||||
}
|
||||
$inherited = $style['desktop'] ?? [];
|
||||
foreach (['tablet', 'mobile'] as $device) {
|
||||
if (!isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] = $image;
|
||||
if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
|
||||
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
||||
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
||||
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
||||
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $style['desktop'][$begleiter];
|
||||
if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $inherited[$begleiter];
|
||||
}
|
||||
}
|
||||
}
|
||||
$inherited = array_replace($inherited, $style[$device] ?? []);
|
||||
}
|
||||
|
||||
return $style;
|
||||
|
||||
@@ -111,6 +111,15 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$item = $this->media->find($id);
|
||||
|
||||
return null === $item ? $this->json(['error' => 'Bild nicht gefunden.'], 404) : $this->json(['item' => $item]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, ?string $id = null): Response
|
||||
|
||||
@@ -76,6 +76,7 @@ final class MenuController extends AbstractController
|
||||
$rows[] = [
|
||||
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||
'newTab' => $item['newTab'] ?? false,
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -101,6 +102,7 @@ final class MenuController extends AbstractController
|
||||
'pageId' => (string) ($row['pageId'] ?? ''),
|
||||
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
||||
'url' => trim((string) ($row['url'] ?? '')),
|
||||
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
];
|
||||
@@ -110,6 +112,9 @@ final class MenuController extends AbstractController
|
||||
$items = [];
|
||||
foreach ($flat as $row) {
|
||||
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
||||
if ($row['newTab']) {
|
||||
$item['newTab'] = true;
|
||||
}
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{% else %}
|
||||
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
|
||||
{% endif %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<label for="link-label">Beschriftung</label><input id="link-label" maxlength="80" placeholder="Kontakt">
|
||||
<label for="link-url">Adresse</label><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt">
|
||||
<p class="field-help">Auch mailto: und tel: sind möglich.</p>
|
||||
<label class="check-label" for="link-new-tab"><input id="link-new-tab" type="checkbox"> In neuem Tab öffnen</label>
|
||||
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
|
||||
</div></details>
|
||||
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
@@ -38,19 +38,20 @@
|
||||
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
|
||||
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %}
|
||||
{% set imageLinked = klick == 'link' and props.href|default('') %}
|
||||
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||
{%- if klick == 'vergroessern' -%}
|
||||
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
|
||||
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
|
||||
{%- elseif props.href|default('') -%}
|
||||
<a class="image-link" href="{{ props.href }}">
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{%- elseif imageLinked -%}
|
||||
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
||||
<h3>{% if props.href|default('') %}<a href="{{ props.href }}">{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
||||
<h3>{% if props.href|default('') %}<a href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||
</div>
|
||||
{% elseif type == 'stat' %}
|
||||
@@ -73,7 +74,7 @@
|
||||
<h2>{{ props.title }}</h2>
|
||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||
</div>
|
||||
<a class="button" href="{{ props.href }}">{{ props.buttonText }}</a>
|
||||
<a class="button" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.buttonText }}</a>
|
||||
</div>
|
||||
{% elseif type == 'contact' %}
|
||||
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
||||
@@ -160,7 +161,7 @@
|
||||
something carries its label as text for screen readers. #}
|
||||
{% set iconBody %}{% include 'blocks/icon.html.twig' with {name: props.icon} only %}{% if not props.decorative %}<span class="visually-hidden">{{ props.label }}</span>{% endif %}{% endset %}
|
||||
{% if props.href|default('') %}
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
{% else %}
|
||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% if newTab|default(false) %} target="_blank" rel="noopener"{% endif -%}
|
||||
@@ -6,12 +6,12 @@
|
||||
<details class="menu-branch">
|
||||
<summary>{{ link.label }}</summary>
|
||||
<div class="menu-branch-body">
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2569,6 +2569,69 @@ final class CmsTest extends TestCase
|
||||
$this->partials->delete($this->partials->findByKind('header')['id']);
|
||||
}
|
||||
|
||||
public function testNewTabLinksPersistForBlocksAndNestedMenus(): void
|
||||
{
|
||||
$document = DemoContent::starter('Links');
|
||||
$document['schemaVersion'] = 20;
|
||||
$types = [
|
||||
'button' => ['text' => 'Öffnen'],
|
||||
'image' => ['src' => '/bild.png', 'width' => 120, 'height' => 80, 'alt' => 'Bild', 'decorative' => false, 'click' => 'link'],
|
||||
'iconcard' => ['title' => 'Karte', 'text' => '', 'icon' => 'star'],
|
||||
'cta' => ['title' => 'Aufruf', 'text' => '', 'buttonText' => 'Öffnen'],
|
||||
'icon' => ['icon' => 'star', 'size' => 24, 'decorative' => false, 'label' => 'Öffnen'],
|
||||
];
|
||||
foreach ($types as $type => $props) {
|
||||
$document['blocks'][] = ['id' => 'new-'.$type, 'type' => $type, 'props' => [...$props, 'href' => '/kontakt', 'newTab' => true]];
|
||||
}
|
||||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $revision, 1);
|
||||
$this->menus->save('header', 'Links', [['label' => 'Eltern', 'type' => 'url', 'url' => '/', 'newTab' => true, 'children' => [['label' => 'Kind', 'type' => 'url', 'url' => '/kontakt', 'newTab' => true]]]], 0);
|
||||
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
|
||||
foreach (['#new-button', '#new-image a', '#new-iconcard a', '#new-cta a', '#new-icon'] as $selector) {
|
||||
self::assertSame('_blank', $crawler->filter($selector)->attr('target'));
|
||||
self::assertSame('noopener', $crawler->filter($selector)->attr('rel'));
|
||||
}
|
||||
self::assertCount(2, $crawler->filter('.site-header .menu-list a[target="_blank"][rel="noopener"]'));
|
||||
$document['blocks'][array_key_last($document['blocks'])]['props']['newTab'] = 'yes';
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testNewTabAndRemovedBackgroundRequireSchemaTwenty(): void
|
||||
{
|
||||
foreach ([['type' => 'button', 'props' => ['text' => 'Link', 'href' => '/', 'newTab' => true]], ['type' => 'section', 'style' => ['tablet' => ['backgroundImage' => 'none']], 'children' => []]] as $block) {
|
||||
$document = DemoContent::starter('Schema');
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][] = ['id' => 'new-option', ...$block];
|
||||
try {
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::fail('An explicit schema upgrade is required.');
|
||||
} catch (InvalidDocument) {
|
||||
$document['schemaVersion'] = 20;
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::assertSame(20, $document['schemaVersion']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRemovingAnInheritedBackgroundAlsoRemovesItOnNarrowerDevices(): void
|
||||
{
|
||||
$id = str_repeat('a', 32);
|
||||
$this->db->insert('media', ['id' => $id, 'title' => 'Klein', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$compiler = new StyleCompiler(new MediaLibrary($this->db, $this->path.'-media'));
|
||||
$document = ['blocks' => [['id' => 'background', 'style' => ['desktop' => ['backgroundImage' => $id], 'tablet' => ['backgroundImage' => 'none']]]]];
|
||||
$css = $compiler->compileDocument($document);
|
||||
self::assertStringContainsString('#background{background-image:url("/media/'.$id.'/192")}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:none}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#background{background-image:none}}', $css);
|
||||
$document['blocks'][0]['style']['desktop'] += ['gradientFrom' => '#000000', 'gradientTo' => '#ffffff', 'gradientAngle' => 90];
|
||||
$gradientCss = $compiler->compileDocument($document);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
|
||||
self::assertStringContainsString('@media (max-width:767px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
|
||||
unset($document['blocks'][0]['style']['tablet']);
|
||||
self::assertStringNotContainsString('background-image:none', $compiler->compileDocument($document));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
@@ -2585,6 +2648,8 @@ final class CmsTest extends TestCase
|
||||
yield 'removes an iframe' => ['<iframe src="https://evil.example"></iframe>ok', 'ok'];
|
||||
yield 'closes unbalanced markup' => ['<strong>unge<em>schlossen', '<strong>unge<em>schlossen</em></strong>'];
|
||||
yield 'keeps a local link' => ['<a href="/kontakt">hier</a>', '<a href="/kontakt">hier</a>'];
|
||||
yield 'new local tab has no opener' => ['<a href="/kontakt" target="_blank" rel="opener">hier</a>', '<a href="/kontakt" target="_blank" rel="noopener">hier</a>'];
|
||||
yield 'rejects named frames' => ['<a href="/kontakt" target="_top">hier</a>', '<a href="/kontakt">hier</a>'];
|
||||
yield 'marks an external link' => ['<a href="https://example.com">dort</a>', '<a href="https://example.com" rel="noopener">dort</a>'];
|
||||
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3<br>Zwei'];
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
foreach (['/admin', '/admin/posts', '/admin/media'] as $url) {
|
||||
foreach (['/admin', '/admin/posts', '/admin/media', '/admin/api/media/'.str_repeat('a', 32)] as $url) {
|
||||
$client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
|
||||
@@ -547,6 +547,22 @@ final class EditorSecurityTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void
|
||||
{
|
||||
$id = str_repeat('a', 32);
|
||||
$this->db->insert('media', ['id' => $id, 'title' => 'Kleines Logo', 'alt' => 'Logo', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 1, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/api/media/'.$id);
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame($id, $data['item']['id']);
|
||||
self::assertSame(192, $data['item']['variants'][0]['width']);
|
||||
self::assertTrue($data['item']['archived']);
|
||||
self::assertArrayNotHasKey('items', $data);
|
||||
$client->request('GET', '/admin/api/media/'.str_repeat('b', 32));
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
|
||||
@@ -305,6 +305,45 @@ test('free styles inherit from the wider device, reset individually and leave no
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('removing an inherited background upgrades the schema and can be reset or undone', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
history.change(doc => setStyle(doc, 's1', 'desktop', 'backgroundImage', 'a'.repeat(32)));
|
||||
assert.equal(history.document.schemaVersion, 10);
|
||||
history.change(doc => setStyle(doc, 's1', 'tablet', 'backgroundImage', 'none'));
|
||||
const block = () => locate(history.document.blocks, 's1').block;
|
||||
assert.equal(history.document.schemaVersion, 20);
|
||||
assert.deepEqual(effectiveStyle(block(), 'mobile', 'backgroundImage'), { value: 'none', source: 'tablet' });
|
||||
history.change(doc => setStyle(doc, 's1', 'tablet', 'backgroundImage', ''));
|
||||
assert.deepEqual(effectiveStyle(block(), 'mobile', 'backgroundImage'), { value: 'a'.repeat(32), source: 'desktop' });
|
||||
history.undo();
|
||||
assert.equal(block().style.tablet.backgroundImage, 'none');
|
||||
history.undo();
|
||||
assert.equal(history.document.schemaVersion, 10);
|
||||
});
|
||||
|
||||
test('replacing an image preserves its link, caption and layout and remains one undo step', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const first = { id: 'a'.repeat(32), alt: 'First', variants: [{ src: '/media/a/192.webp', width: 192, height: 96 }] };
|
||||
const second = { id: 'b'.repeat(32), alt: 'Second', variants: [{ src: '/media/b/480.webp', width: 480, height: 320 }] };
|
||||
const galleryId = history.change(doc => insertMedia(doc, 's1', [first]));
|
||||
const id = locate(history.document.blocks, galleryId).block.children[0].id;
|
||||
history.change(doc => {
|
||||
const block = locate(doc.blocks, id).block;
|
||||
Object.assign(block.props, { href: '/kontakt', click: 'link', newTab: true, caption: 'Meine Bildunterschrift' });
|
||||
setStyle(doc, id, 'desktop', 'width', '50%'); upgradeDocument(doc, 20);
|
||||
});
|
||||
const before = structuredClone(history.document);
|
||||
history.change(doc => insertMedia(doc, id, [second]));
|
||||
const replaced = locate(history.document.blocks, id).block;
|
||||
assert.equal(replaced.props.mediaId, second.id);
|
||||
assert.equal(replaced.props.width, 480); assert.equal(replaced.props.height, 320);
|
||||
assert.equal(replaced.props.alt, 'Second'); assert.equal(replaced.props.href, '/kontakt');
|
||||
assert.equal(replaced.props.click, 'link'); assert.equal(replaced.props.newTab, true);
|
||||
assert.equal(replaced.props.caption, 'Meine Bildunterschrift');
|
||||
assert.equal(replaced.style.desktop.width, '50%');
|
||||
history.undo(); assert.deepEqual(history.document, before);
|
||||
});
|
||||
|
||||
test('a row arrives with its columns, inside the selected container', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
|
||||
|
||||
Reference in new issue
Block a user