diff --git a/README.md b/README.md index 7d6d165..0ccba57 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/VERSION b/VERSION index f8a696c..661e7ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.2 +1.7.3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fccb865..7fab50c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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..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..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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 1da3a64..2f20d53 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/package.json b/package.json index 0196c3e..155de3d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/assets/admin.css b/public/assets/admin.css index 99eee49..8ef1e4f 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -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%} diff --git a/public/assets/design-panel.js b/public/assets/design-panel.js index e8ee76e..7b9a67c 100644 --- a/public/assets/design-panel.js +++ b/public/assets/design-panel.js @@ -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); } diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index aa2bb0e..3143105 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -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] ??= {}; diff --git a/public/assets/editor.css b/public/assets/editor.css index bcce2f8..a7f9c1f 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -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} diff --git a/public/assets/editor.js b/public/assets/editor.js index 06a1672..e1a9de5 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -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.' }); diff --git a/public/assets/media.js b/public/assets/media.js index fe88bc0..79f0a61 100644 --- a/public/assets/media.js +++ b/public/assets/media.js @@ -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() { diff --git a/public/assets/menu-editor.js b/public/assets/menu-editor.js index 2b6f2d5..3e731ec 100644 --- a/public/assets/menu-editor.js +++ b/public/assets/menu-editor.js @@ -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); }); diff --git a/public/assets/rich-text.js b/public/assets/rich-text.js index b471543..1a9ec5e 100644 --- a/public/assets/rich-text.js +++ b/public/assets/rich-text.js @@ -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; diff --git a/scripts/link-media-check.mjs b/scripts/link-media-check.mjs new file mode 100644 index 0000000..d1ceebf --- /dev/null +++ b/scripts/link-media-check.mjs @@ -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: '

Textlink prüfen

' } }, + { 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(); } diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 4c469de..2d49603 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -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; diff --git a/src/Content/MenuRepository.php b/src/Content/MenuRepository.php index a4e487c..147e3a3 100644 --- a/src/Content/MenuRepository.php +++ b/src/Content/MenuRepository.php @@ -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.'); diff --git a/src/Content/RichText.php b/src/Content/RichText.php index d45afcb..b379008 100644 --- a/src/Content/RichText.php +++ b/src/Content/RichText.php @@ -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'); } } diff --git a/src/Content/StyleCompiler.php b/src/Content/StyleCompiler.php index e86518b..99270a9 100644 --- a/src/Content/StyleCompiler.php +++ b/src/Content/StyleCompiler.php @@ -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; diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php index fefa8db..fe25dd8 100644 --- a/src/Controller/MediaController.php +++ b/src/Controller/MediaController.php @@ -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 diff --git a/src/Controller/MenuController.php b/src/Controller/MenuController.php index 763f976..2b3db0f 100644 --- a/src/Controller/MenuController.php +++ b/src/Controller/MenuController.php @@ -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 { diff --git a/templates/admin/menu-entry.html.twig b/templates/admin/menu-entry.html.twig index f9eaedf..ad21486 100644 --- a/templates/admin/menu-entry.html.twig +++ b/templates/admin/menu-entry.html.twig @@ -12,6 +12,7 @@ {% else %} {% endif %} +
diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index 26a1152..fab499c 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -17,6 +17,7 @@

Auch mailto: und tel: sind möglich.

+

Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.

diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 5664093..b939f2b 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -24,7 +24,7 @@

{{ props.text }}

{% endif %} {% elseif type == 'button' %} - {{ props.text }} + {{ props.text }} {% 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 %}
{%- 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. #} - {%- elseif props.href|default('') -%} - - {%- endif -%}{{ props.alt }}{% if klick == 'vergroessern' or props.href|default('') %}{% endif %}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %}
+ {%- elseif imageLinked -%} + + {%- endif -%}{{ props.alt }}{% if klick == 'vergroessern' or imageLinked %}{% endif %}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %} {% elseif type == 'iconcard' %}
{% include 'blocks/icon.html.twig' with {name: props.icon} only %} -

{% if props.href|default('') %}{{ props.title }}{% else %}{{ props.title }}{% endif %}

+

{% if props.href|default('') %}{{ props.title }}{% else %}{{ props.title }}{% endif %}

{% if props.text|default('') %}

{{ props.text }}

{% endif %}
{% elseif type == 'stat' %} @@ -73,7 +74,7 @@

{{ props.title }}

{% if props.text|default('') %}

{{ props.text }}

{% endif %} - {{ props.buttonText }} + {{ props.buttonText }} {% elseif type == 'contact' %}
@@ -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 %}{{ props.label }}{% endif %}{% endset %} {% if props.href|default('') %} - + {% else %} {% endif %} diff --git a/templates/blocks/link-attributes.html.twig b/templates/blocks/link-attributes.html.twig new file mode 100644 index 0000000..bb98e05 --- /dev/null +++ b/templates/blocks/link-attributes.html.twig @@ -0,0 +1 @@ +{% if newTab|default(false) %} target="_blank" rel="noopener"{% endif -%} diff --git a/templates/nav.html.twig b/templates/nav.html.twig index 30dc682..de99a8e 100644 --- a/templates/nav.html.twig +++ b/templates/nav.html.twig @@ -6,12 +6,12 @@ {% else %} - {{ link.label }} + {{ link.label }} {% endif %} {% endfor %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index a1e9427..f597055 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -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 */ @@ -2585,6 +2648,8 @@ final class CmsTest extends TestCase yield 'removes an iframe' => ['ok', 'ok']; yield 'closes unbalanced markup' => ['ungeschlossen', 'ungeschlossen']; yield 'keeps a local link' => ['hier', 'hier']; + yield 'new local tab has no opener' => ['hier', 'hier']; + yield 'rejects named frames' => ['hier', 'hier']; yield 'marks an external link' => ['dort', 'dort']; yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3
Zwei']; } diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index 5bdfc21..794b609 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -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'); diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index bb26d08..4e65aba 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -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());