Compare commits

..
4 Commits
Author SHA1 Message Date
kantarci 25fd388f80 fix: reliably reveal image link fields on selection (1.7.4)
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:56:40 +02:00
kantarci e18c881db8 docs: record verified 1.7.3 release artifacts
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:50:19 +02:00
kantarci 6d21c452bd feat: add new-tab links and removable media previews (1.7.3)
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:48:44 +02:00
kantarci b8991ddd5c Dokumentiert das geprüfte Release 1.7.2
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:21:59 +02:00
28 changed files with 566 additions and 54 deletions

No files matched your search

+2
View File
@@ -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/`. **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. 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. 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.
+1 -1
View File
@@ -1 +1 @@
1.7.2 1.7.4
+12
View File
@@ -2,6 +2,18 @@
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. 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.
**Korrektur 1.7.4:** Native Inhalts-Auswahlfelder verarbeiten `input` und `change`, identische aufeinanderfolgende Werte werden nur einmal übernommen. Jede Auswahl ist ein eigener Undo-Schritt. Die Bild-Klickaktion ersetzt beim Wechsel nicht mehr den ganzen Feinschliff: Linkziel und Tab-Häkchen werden in einer bestehenden Gruppe ein-/ausgeblendet. Das erhält Dropdown, Fokus und eingegebene Adresse. Ein Test mit ausschließlich `change` konnte das fehlende URL-Feld vor der Korrektur reproduzieren; native Auswahl und Speicherung werden zusätzlich geprüft. Keine Änderung des Datenvertrags.
## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026) ## 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. 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.
+27 -1
View File
@@ -1,12 +1,38 @@
# Projektstatus # Projektstatus
## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026)
**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4.
**Geprüft:** Der ergänzte Browserfall mit ausschließlich `change` scheiterte vor dem Fix am fehlenden URL-Feld (`var/image-link-before.log`). Danach `npm run test:link-media` mit sechs Prüfgruppen bestanden: erstmals verlinktes Bild, natives Dropdown mit Mausöffnung/Tastaturwahl, Tastaturwechsel, stabiler DOM-Knoten/Fokus, Nur-change-Ereignis, URL-Erhalt, Vergrößern/Nichts tun, Undo/Redo, Speichern/Laden und veröffentlichte Zieladresse. Bestehende Bild-, Hintergrund-, Textlink- und Menüprüfungen ebenfalls erfolgreich. Desktop-/Mobil-Editor und öffentliche Seite ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). 32 JavaScript-Tests bestanden. Schreibtests ausschließlich in isolierten Kant-IT-Kopien.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Inhalts-/Design-Auswahl, Undo, Speichern, Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`).
**Veröffentlichung:** Release-Paket 1.7.4 wird aus dem geprüften Commit gebaut und auf dem bestehenden Git bereitgestellt.
**Grenzen / nächster Schritt:** Kein erneuter PHP- oder Lighthouse-Lauf für diese reine Editor-Korrektur. Kundenhostings werden nicht verändert; nach Einspielen des Updates den Editor neu öffnen, damit die versionierten Skripte geladen werden.
## 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öffentlicht:** Commit `6d21c45` und Tag `v1.7.3` auf `origin`; [Release 1.7.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3532 Archivdateien, 209 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `acf6a4ebea555fe7613935372e1a7922aca4e2c72646175a93d349359787e315`.
**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) ## 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. **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.
**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien. **Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien.
**Offen:** Geprüfte Fassung als 1.7.2 veröffentlichen. Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft. **Veröffentlicht:** Commit `0920e3c` und Tag `v1.7.2` auf `origin`; [Release 1.7.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3531 Archivdateien, 208 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `e9983669871815e46c8a675bf59eb3632f37f015c0026ccb072a93119def5ac8`.
**Grenzen und nächster Schritt:** Das Update kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft.
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026) ## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
+2 -1
View File
@@ -11,7 +11,8 @@
"audit": "node scripts/audit.mjs", "audit": "node scripts/audit.mjs",
"test:admin": "node scripts/admin-quality-check.mjs", "test:admin": "node scripts/admin-quality-check.mjs",
"test:search": "node scripts/live-search-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": { "devDependencies": {
"@axe-core/playwright": "^4.10.0", "@axe-core/playwright": "^4.10.0",
+1
View File
@@ -708,6 +708,7 @@ details.card > summary:hover { background: #fafbf8 }
/* Search and forms share usable widths throughout administration. */ /* 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} .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} .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{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%} .list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
+11 -7
View File
@@ -6,7 +6,7 @@
* appear here without touching this file. * appear here without touching this file.
*/ */
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js'; 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 = { export const GROUPS = {
text: 'Typografie', text: 'Typografie',
@@ -444,15 +444,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const mediaControl = (key, value, apply) => { const mediaControl = (key, value, apply) => {
const wrap = element('div', 'media-control'); 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 hasImage = Boolean(value && value !== 'none');
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek'); const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
choose.type = 'button'; 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); } })); choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
wrap.append(status, choose); wrap.append(preview.element, choose);
if (value) { if (hasImage) {
const remove = element('button', 'text-button danger-text', 'Bild entfernen'); const remove = element('button', 'text-button danger-text', 'Bild entfernen');
remove.type = 'button'; 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); wrap.append(remove);
} }
return wrap; 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 === '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 === '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 === '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; else continue;
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) })); 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) { 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; if (property.type === 'choice') return property.values[value] ?? value;
return String(value); return String(value);
} }
+3 -2
View File
@@ -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 // 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 // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle. // 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; const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
export function upgradeDocument(document, version = 2) { export function upgradeDocument(document, version = 2) {
@@ -310,7 +310,7 @@ export function mediaProps(media) {
export function insertMedia(document, selected, items) { export function insertMedia(document, selected, items) {
const target = locate(document.blocks, selected)?.block; const target = locate(document.blocks, selected)?.block;
upgradeDocument(document, 3); 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; let parent = target;
if (target?.type !== 'gallery') { if (target?.type !== 'gallery') {
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block; 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; if (!block) return;
const empty = value === '' || value === null || value === undefined; const empty = value === '' || value === null || value === undefined;
if (!empty) upgradeDocument(document, 10); if (!empty) upgradeDocument(document, 10);
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
block.style ??= {}; block.style ??= {};
block.style[device] ??= {}; block.style[device] ??= {};
+1
View File
@@ -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-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{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 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{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:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px} .rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
+47 -9
View File
@@ -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 { 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 { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js'; import { createRichText } from './rich-text.js';
import { createCanvasOverlay } from './canvas-overlay.js'; import { createCanvasOverlay } from './canvas-overlay.js';
@@ -177,7 +177,9 @@ function renderProperties() {
} }
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; } if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; } if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
let imagePreview;
const field = (label, key, value, options = {}) => { const field = (label, key, value, options = {}) => {
const container = options.container || panel;
const id = `property-${key}`; const id = `property-${key}`;
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label; const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
let input; let input;
@@ -189,8 +191,13 @@ function renderProperties() {
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; } if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); } if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
if (options.maxLength) input.maxLength = options.maxLength; if (options.maxLength) input.maxLength = options.maxLength;
input.addEventListener('input', () => { let previousChoice = input.value;
const applyValue = () => {
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value; const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
// Native selects may emit input, change, or both. Apply each selection once and keep
// the control in place while its popup or keyboard navigation is active.
if (options.choices && input.value === previousChoice) return;
previousChoice = input.value;
history.change(doc => { history.change(doc => {
const current = selected ? locate(doc.blocks, selected)?.block : doc; const current = selected ? locate(doc.blocks, selected)?.block : doc;
if (!current) return; if (!current) return;
@@ -203,14 +210,20 @@ function renderProperties() {
else current[key] = next; else current[key] = next;
if (key === 'caption') upgradeDocument(doc, 3); if (key === 'caption') upgradeDocument(doc, 3);
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19); 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; } if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, `${selected || 'page'}:${key}`); }, options.choices ? null : `${selected || 'page'}:${key}`);
if (key === 'decorative') renderProperties(); if (key === 'src') imagePreview?.update({ src: input.value });
options.onInput?.(next);
if (key === 'decorative') { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); }
renderTree(); controls(); schedulePreview(); renderTree(); controls(); schedulePreview();
}); };
input.addEventListener('input', applyValue);
if (options.choices) input.addEventListener('change', applyValue);
input.addEventListener('blur', () => { history.mergeKey = null; }); input.addEventListener('blur', () => { history.mergeKey = null; });
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input); if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); } if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
}; };
if (!block) { if (!block) {
field('Seitentitel', 'title', history.document.title, { maxLength: 160 }); field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
@@ -334,10 +347,19 @@ function renderProperties() {
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' }); if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'image') { if (block.type === 'image') {
renderImageWidth(block, panel); renderImageWidth(block, panel);
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.' }); imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
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 /.' }); panel.append(imagePreview.element);
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
showLinkOptions(clickAction); panel.append(linkOptions);
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); 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('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 }); field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true }); if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
@@ -351,6 +373,22 @@ function renderProperties() {
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen'; const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker); 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') { 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('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.' }); 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.' });
+41
View File
@@ -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 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 } = {}) { export function openMedia({ multiple = false, onChoose } = {}) {
const opener = document.activeElement; const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog'; 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.'); if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`); 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; return data;
} }
function render() { function render() {
+8 -3
View File
@@ -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 el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
const rows = () => [...list.children].map(row => { const rows = () => [...list.children].map(row => {
const data = { level: Number(row.dataset.level) }; 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.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? ''; data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
return data; return data;
@@ -49,7 +49,10 @@ function createRow(data) {
const field = (key, value, label, type = 'text') => { const field = (key, value, label, type = 'text') => {
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? ''; const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
input.id = `menu-${key}-${id}`; 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); else row.append(input);
return input; return input;
}; };
@@ -68,6 +71,7 @@ function createRow(data) {
body.append(picker); setupPicker(picker); body.append(picker); setupPicker(picker);
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65; field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; } } 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'); 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' })) { 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); 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', () => { form.querySelector('#menu-add-url').addEventListener('click', () => {
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url'); 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; } 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('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); }); form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
+22 -5
View File
@@ -83,7 +83,10 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
linkInput.type = 'text'; linkInput.type = 'text';
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…'; linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
linkInput.setAttribute('aria-label', 'Linkziel'); 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 confirmLink = () => {
const target = linkInput.value.trim(); const target = linkInput.value.trim();
linkRow.hidden = true; linkRow.hidden = true;
@@ -93,8 +96,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(savedRange); selection.addRange(savedRange);
} }
if (target) document.execCommand('createLink', false, target); if (target) {
else document.execCommand('unlink'); 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); push(surface.innerHTML);
linkInput.value = ''; linkInput.value = '';
}; };
@@ -102,14 +113,20 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); } if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); } 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', () => { bar.append(button('🔗', 'Link setzen oder entfernen', () => {
const selection = window.getSelection(); 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; linkRow.hidden = false;
linkInput.focus(); 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 toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
const toSource = source.hidden; const toSource = source.hidden;
+212
View File
@@ -0,0 +1,212 @@
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-empty-image', type: 'image', props: { ...original, click: 'keine' } },
{ 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-empty-image');
const clickAction = () => page.getByLabel('Beim Klick', { exact: true });
await page.locator('#property-href').waitFor({ state: 'hidden' });
const nativeSelect = await clickAction().elementHandle();
await clickAction().click(); await page.keyboard.press('End'); await page.keyboard.press('Enter');
await page.getByLabel('Linkziel', { exact: true }).waitFor();
assert.equal(await nativeSelect.evaluate(el => el.isConnected), true, 'Keep the native dropdown mounted while changing the action');
await nativeSelect.dispose();
await page.getByLabel('Linkziel', { exact: true }).fill('/kontakt');
await clickAction().focus(); await page.keyboard.press('Home');
await page.locator('#property-href').waitFor({ state: 'hidden' });
await page.keyboard.press('End'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
await clickAction().selectOption('keine');
await clickAction().evaluate(el => { el.value = 'link'; el.dispatchEvent(new Event('change', { bubbles: true })); });
await page.getByLabel('Linkziel', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
await page.locator('#undo').click();
assert.equal(await clickAction().inputValue(), 'keine'); await page.locator('#property-href').waitFor({ state: 'hidden' });
await page.locator('#redo').click();
assert.equal(await clickAction().inputValue(), 'link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
await clickAction().selectOption('vergroessern');
await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#property-newTab').waitFor({ state: 'hidden' });
await frame.locator('#qa-empty-image a.image-zoom').waitFor();
await clickAction().selectOption('link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
await page.locator('.image-link-options').screenshot({ path: `${report}/image-link-fields.png` });
checks.push('fresh image URL appears after native dropdown and keyboard selection; URL retained; change-only event supported');
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 }).focus();
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 select('qa-empty-image');
assert.equal(await clickAction().inputValue(), 'link');
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
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');
assert.equal(await publicPage.locator('#qa-empty-image a').getAttribute('href'), '/kontakt');
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(); }
+10 -1
View File
@@ -42,7 +42,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void public function validate(array $document, bool $requireHeading = true): void
{ {
$schemaVersion = $document['schemaVersion'] ?? null; $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['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320); $this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); $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); $limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
$this->text($props['text'] ?? null, $limit); $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) { if ('heading' === $type) {
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.'); $this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
$headings += 1 === $props['level'] ? 1 : 0; $headings += 1 === $props['level'] ? 1 : 0;
+4 -1
View File
@@ -132,7 +132,7 @@ final class MenuRepository
if ('' === trim($label)) { if ('' === trim($label)) {
continue; 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; 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) { 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.'); 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 ('page' === ($item['type'] ?? null)) {
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) { 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.'); throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
+5 -2
View File
@@ -21,7 +21,7 @@ final class RichText
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [], 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [], 'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
'ul' => [], 'ol' => [], 'li' => [], 'ul' => [], 'ol' => [], 'li' => [],
'a' => ['href'], 'a' => ['href', 'target'],
]; ];
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */ /** 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']; private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
@@ -121,7 +121,10 @@ final class RichText
continue; continue;
} }
$child->setAttribute('href', $href); $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'); $child->setAttribute('rel', 'noopener');
} }
} }
+9 -11
View File
@@ -84,7 +84,7 @@ final class StyleCompiler
sprintf('"%s" is outside its range.', $key), 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)), '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.'), default => $this->require(false, 'Unknown style type.'),
}; };
} }
@@ -233,7 +233,7 @@ final class StyleCompiler
$this->css(['type' => 'color'], (string) $values['gradientTo']), $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']); $item = $this->media->find((string) $values['backgroundImage']);
if (null !== $item && [] !== $item['variants']) { if (null !== $item && [] !== $item['variants']) {
// A phone should not download the 2400 pixel variant of a background. CSS has no // 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 private function shadow(array $values): string
@@ -348,21 +348,19 @@ final class StyleCompiler
*/ */
private function scaleBackground(array $style): array private function scaleBackground(array $style): array
{ {
$image = $style['desktop']['backgroundImage'] ?? null; $inherited = $style['desktop'] ?? [];
if (null === $image) {
return $style;
}
foreach (['tablet', 'mobile'] as $device) { foreach (['tablet', 'mobile'] as $device) {
if (!isset($style[$device]['backgroundImage'])) { if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
$style[$device]['backgroundImage'] = $image; $style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem // Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text. // Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) { foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) { if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
$style[$device][$begleiter] = $style['desktop'][$begleiter]; $style[$device][$begleiter] = $inherited[$begleiter];
} }
} }
} }
$inherited = array_replace($inherited, $style[$device] ?? []);
} }
return $style; return $style;
+9
View File
@@ -111,6 +111,15 @@ final class MediaController extends AbstractController
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]); 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/upload', methods: ['POST'])]
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], 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 public function write(Request $request, ?string $id = null): Response
+5
View File
@@ -76,6 +76,7 @@ final class MenuController extends AbstractController
$rows[] = [ $rows[] = [
'label' => $item['label'], 'type' => $item['type'], 'level' => $level, 'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '', 'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
'newTab' => $item['newTab'] ?? false,
]; ];
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1)); array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
} }
@@ -101,6 +102,7 @@ final class MenuController extends AbstractController
'pageId' => (string) ($row['pageId'] ?? ''), 'pageId' => (string) ($row['pageId'] ?? ''),
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'), 'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
'url' => trim((string) ($row['url'] ?? '')), 'url' => trim((string) ($row['url'] ?? '')),
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))), 'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
'position' => (int) ($row['position'] ?? 0), 'position' => (int) ($row['position'] ?? 0),
]; ];
@@ -110,6 +112,9 @@ final class MenuController extends AbstractController
$items = []; $items = [];
foreach ($flat as $row) { foreach ($flat as $row) {
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []]; $item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
if ($row['newTab']) {
$item['newTab'] = true;
}
if ('url' === $row['type']) { if ('url' === $row['type']) {
$item['url'] = $row['url']; $item['url'] = $row['url'];
} else { } else {
+1
View File
@@ -12,6 +12,7 @@
{% else %} {% 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"> <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 %} {% 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> </div>
</details> </details>
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen"> <div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
+1
View File
@@ -17,6 +17,7 @@
<label for="link-label">Beschriftung</label><input id="link-label" maxlength="80" placeholder="Kontakt"> <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"> <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> <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> <button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
</div></details> </div></details>
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p> <p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
+8 -7
View File
@@ -24,7 +24,7 @@
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p> <p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
{% endif %} {% endif %}
{% elseif type == 'button' %} {% 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' %} {% elseif type == 'image' %}
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit {# 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 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 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 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 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 %} {% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"> <figure id="{{ block.id }}" class="block-image{{ designClasses }}">
{%- if klick == 'vergroessern' -%} {%- if klick == 'vergroessern' -%}
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der {# 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. #} 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"> <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('') -%} {%- elseif imageLinked -%}
<a class="image-link" href="{{ props.href }}"> <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 props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure> {%- 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' %} {% elseif type == 'iconcard' %}
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}"> <div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
{% include 'blocks/icon.html.twig' with {name: props.icon} only %} {% 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 %} {% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
</div> </div>
{% elseif type == 'stat' %} {% elseif type == 'stat' %}
@@ -73,7 +74,7 @@
<h2>{{ props.title }}</h2> <h2>{{ props.title }}</h2>
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %} {% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
</div> </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> </div>
{% elseif type == 'contact' %} {% elseif type == 'contact' %}
<address id="{{ block.id }}" class="block-contact{{ designClasses }}"> <address id="{{ block.id }}" class="block-contact{{ designClasses }}">
@@ -160,7 +161,7 @@
something carries its label as text for screen readers. #} 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 %} {% 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('') %} {% 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 %} {% else %}
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span> <span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
{% endif %} {% endif %}
@@ -0,0 +1 @@
{% if newTab|default(false) %} target="_blank" rel="noopener"{% endif -%}
+2 -2
View File
@@ -6,12 +6,12 @@
<details class="menu-branch"> <details class="menu-branch">
<summary>{{ link.label }}</summary> <summary>{{ link.label }}</summary>
<div class="menu-branch-body"> <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 %} {% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
</div> </div>
</details> </details>
{% else %} {% 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 %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
+65
View File
@@ -2569,6 +2569,69 @@ final class CmsTest extends TestCase
$this->partials->delete($this->partials->findByKind('header')['id']); $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}> * @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 '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 '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 '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 '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 &lt;3<br>Zwei']; yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins &lt;3<br>Zwei'];
} }
+17 -1
View File
@@ -145,7 +145,7 @@ final class EditorSecurityTest extends WebTestCase
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
{ {
$client = self::createClient(); $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']); $client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
self::assertResponseRedirects('http://localhost/admin/login'); self::assertResponseRedirects('http://localhost/admin/login');
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent()); 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 public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
{ {
$client = $this->login('editor'); $client = $this->login('editor');
+39
View File
@@ -305,6 +305,45 @@ test('free styles inherit from the wider device, reset individually and leave no
assert.deepEqual(history.document, fixture()); 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', () => { test('a row arrives with its columns, inside the selected container', () => {
const history = new EditorHistory(fixture()); const history = new EditorHistory(fixture());