Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3029966cb | ||
|
|
a108e7d143 | ||
|
|
55dab4bc1e | ||
|
|
c1b152b376 | ||
|
|
8f475d1374 | ||
|
|
ab86312e87 |
No files matched your search
@@ -30,6 +30,12 @@ Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website
|
||||
|
||||
## Anmelden und bearbeiten
|
||||
|
||||
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
|
||||
|
||||
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
|
||||
|
||||
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
|
||||
|
||||
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
|
||||
|
||||
Die bisherige Vorschau verhinderte Standard-Klickaktionen einschließlich des Öffnens von `<details>`. Einträge mit `props.open: false` blieben deshalb geschlossen; für leere Akkordeon-Einträge fehlten zusätzlich die Einfügehilfen. Ein Browserfall reproduzierte den geschlossenen Zustand nach Anklicken des Titels.
|
||||
|
||||
`preview-containers.js` öffnet Akkordeon-Einträge ausschließlich im Vorschau-DOM. Leere Einträge zeigen eine Einfügefläche, gefüllte Einträge eine Schaltfläche zum Anhängen weiterer Inhalte. Eine eigene Aktion am Akkordeon legt Einträge an, auch wenn es vollständig leer ist. Die rechte Inhaltsansicht listet Einträge mit ihren Titeln und bietet dieselben Aktionen. Neu eingefügte Akkordeons wählen ihren ersten Eintrag und den Inhaltsbereich; Struktur und Struktursuche berücksichtigen Titel.
|
||||
|
||||
Die Modellauswahl eines Akkordeons erlaubt direkt nur neue Einträge. Texte und andere Inhalte erfordern die Auswahl des Eintrags, damit sie nicht unbemerkt außerhalb des Akkordeons landen. Der gesonderte Befehl für neue Sektionen auf Seitenebene bleibt erhalten. Explizite Einfügepunkte und Drag-and-drop verwenden weiterhin die bestehenden Eltern-/Kindregeln; „Inhalt hinzufügen“ hängt innerhalb des angesprochenen Eintrags an.
|
||||
|
||||
**Migration und Rückweg:** Kein Schemawechsel und keine Datenmigration. Öffnungszustand, Kinder, IDs und Reihenfolge bleiben normale Blockwerte. Vorschauhilfen werden nie gespeichert; die öffentliche Ausgabe und deren native Tastatur-/Mausbedienung bleiben unverändert. Älterer Code kann die Daten weiterhin lesen, zeigt jedoch wieder die bisherige eingeschränkte Vorschau. Alle Inhaltsänderungen laufen über den vorhandenen Undo-/Speicher-/Veröffentlichungsweg.
|
||||
|
||||
## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026)
|
||||
|
||||
Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt.
|
||||
|
||||
Palettenwahl und bestätigte Texteingaben sind einzelne Undo-Schritte. Eine Bewegung im nativen Wähler aktualisiert die Vorschau mit einem gemeinsamen History-Schlüssel und erhält den DOM-Knoten bis zum Abschluss. Danach werden Gerätemarkierungen, Reset und Kontrasthinweis aktualisiert. Farbauswahl per Tastatur, Escape, Fokuswiederherstellung und lokale Fehlermeldung sind Teil desselben Bedienelements. Die Farbkacheln verwenden beschriftete Buttons mit Auswahlzustand; lange Paletten bleiben innerhalb des Panels scrollbar. Keine zusätzliche Bibliothek und kein öffentliches Skript. Das neue Modul steht in der versionierten Importmap.
|
||||
|
||||
**Migration:** Keine Änderung an Datenbank oder Dokumentformat. Bereits erlaubte sechs-/achtstellige Hexwerte und Palettenreferenzen werden unverändert gespeichert. Ein Rückweg auf 1.8.1 zeigt dieselben Werte im früheren Farbfeld. Verwaltungsformulare außerhalb des visuellen Editors behalten ihre bisherigen Felder.
|
||||
|
||||
## ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026)
|
||||
|
||||
Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen `legal`-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt.
|
||||
|
||||
Normale Links, Menüs und bewusst eingefügte „Rechtliches“-Blöcke bleiben erhalten. Der optionale Block nutzt weiterhin die konfigurierten Seitenadressen und den Link zur Einwilligung. Einwilligungsdialog, Einstellungen und der Endpunkt `/einwilligung` behalten ihre Funktion. Keine Inhalte werden umgeschrieben, keine neue Schema- oder Datenbankmigration. Bei einem Rückweg auf ältere Versionen erscheint deren automatische Leiste wieder.
|
||||
|
||||
Diese Entscheidung ersetzt die automatische Verlinkung aus ADR 033 und die bedingte Zusatzleiste aus ADR 062; Position und Gestaltung der Links liegen beim Autor.
|
||||
|
||||
## ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
|
||||
|
||||
`website.settings.notFoundPageId` verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
|
||||
|
||||
+37
-1
@@ -1,5 +1,41 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.8.3 – Inhalte in Akkordeons sichtbar bearbeiten (14.09.2026)
|
||||
|
||||
**Behoben:** Geschlossene Akkordeon-Einträge ließen sich in der Editorvorschau nicht öffnen, weil die Klickbehandlung die native Aktion verhinderte. Der Fehler wurde vor der Korrektur im Browser reproduziert (`var/accordion-before.log`, `reports/accordion/before.png`). Einträge sind im Editor jetzt geöffnet, leere Inhaltsbereiche sichtbar und als Ablageziel nutzbar. „Inhalt hinzufügen“ im Eintrag und rechts im Feinschliff hängt neue Elemente an. „Eintrag hinzufügen“ steht an der Gruppe und in deren Inhaltsansicht; auch ein vollständig leeres Akkordeon ist wieder befüllbar.
|
||||
|
||||
**Bedienung:** Rechte Eintragsübersicht, Struktur und Struktursuche zeigen Eintragstitel. Ein neues Akkordeon wählt den ersten Eintrag samt Inhaltsoptionen. Bei Auswahl der Gruppe werden Texte nicht mehr unbemerkt außerhalb des Akkordeons eingefügt. Die gespeicherte Einstellung „Beim Laden geöffnet“ gilt weiterhin für Besucher; Öffnen im Editor verändert weder Daten noch Speicherstatus. Keine Migration und kein geänderter öffentlicher Renderer (ADR 085).
|
||||
|
||||
**Geprüft:** `npm run test:accordion` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: sichtbarer Inhalt trotz geschlossenem Besucherzustand, Einfügen über Tastatur/Vorschau/Feinschliff, Textbearbeitung, Anhängen und Drag-and-drop von Buttons in Einträge, Undo/Redo, weitere/leere/verschachtelte Akkordeons, Suche nach Titeln, Speichern/Laden und Veröffentlichung. Veröffentlichte Einträge behalten ihre Öffnungswerte und lassen sich ohne JavaScript per Maus/Enter bedienen; Vorschauhilfen fehlen im öffentlichen HTML und in gespeicherten Daten. Desktop 1600/Mobil 390 ohne axe-Verstöße im Editor oder horizontalen Überlauf; Screenshots visuell geprüft (`reports/accordion/`). Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Medien, Einwilligung und Desktop-/Mobil-Barrierefreiheit. 32 JavaScript-Tests erfolgreich, darunter ergänzte Einfüge-/Undo-Prüfungen. 16 gezielte PHP-Tests / 33 Assertions zu V6-Blockvalidierung, Speichern und Ausgabe ohne JavaScript erfolgreich. Produktionscontainer gültig und Git-Diff ohne Whitespacefehler. Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Editoränderung.
|
||||
|
||||
**Veröffentlichung / nächster Schritt:** Release 1.8.3 aus dem geprüften Commit bereitstellen. Danach über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings werden hier nicht verändert.
|
||||
|
||||
## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084).
|
||||
|
||||
**Geprüft:** `npm run test:colors` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: Token-Vorschau, Auswahl/Kanvas, Tastatur/Tab/Escape/Fokus, Undo/Redo, Geräte-/Hover-Vererbung und Reset, stabiler nativer Farbwähler mit einem Undo-Schritt, reine change-Ereignisse, ungültige Eingaben, Kurzhex, achtstellige Hexwerte, Speichern/Laden und Veröffentlichung mit erhaltener Palettenreferenz. Desktop 1600/Mobil 390: geöffnete Farbauswahl und Editor ohne axe-Verstöße oder horizontalen Überlauf; Screenshots `reports/color-picker/` visuell geprüft. Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Gestaltung, Vorlagen, Speichern/Veröffentlichen, Konflikten, Mediathek, Einwilligung sowie Desktop-/Mobil-Barrierefreiheit. Der bisherige Test zur automatisch angehängten Einwilligungszeile wurde an die bereits veröffentlichte Änderung aus 1.8.1 angepasst. 32 JavaScript-Tests einschließlich versionierter Modulimporte bestanden. Geänderte Twig-Basis und Produktionscontainer gültig; Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Veröffentlicht:** Commit `55dab4b` und Tag `v1.8.2` auf `origin`; [Release 1.8.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f6d2a6ff99f52f3a0939160e26403aca13bb9fb9d7042f38255dd958057ae8f5`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Änderung am Editor. Verwaltungsformulare außerhalb des Editors behalten ihre bisherigen Farbfelder. Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.1 – Keine automatische Rechtszeile unter dem Footer (14.09.2026)
|
||||
|
||||
**Behoben:** Das CMS hängt Datenschutz, Impressum und Einwilligungslink nicht mehr automatisch an den Footer an. Entfernt aus Seiten-/Archiv-/Fehlervorlagen und damit auch aus Seiten- und Bereichsvorschauen. Normale Textlinks, Menüs und ausdrücklich eingefügte „Rechtliches“-Bausteine bleiben erhalten; der Editorhinweis beschreibt die freie Platzierung. Keine Inhaltsänderung oder Migration (ADR 083).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3815 Assertions und 32 JavaScript-Tests erfolgreich. Vorhandene Tests an die redaktionelle Platzierung angepasst; freiwilliger Rechtsblock und Einwilligungsablauf weiterhin geprüft. 47 Twig-Dateien und Produktionscontainer gültig, PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Änderungen, Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Browser:** Isolierte Kant-IT-Kopie mit bestehenden Rechtsblöcken sowie einer Variante mit normalen Footer-Textlinks wie im Screenshot. Acht Prüfgruppen erfolgreich: Startseite, Beitragsarchiv und 404 auf Desktop 1600/Mobil 390 sowie Seiten- und Footer-Editor. Eigene Links erhalten, keine Zusatzleiste, kein horizontaler Überlauf. Screenshots unter `reports/footer-links/` visuell geprüft. Gegenprobe mit dem unveränderten Release 1.8.0 reproduziert die doppelte Leiste auf allen sechs öffentlichen Ansichten (`reports/footer-links-before/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Bekannte Prüfbefunde:** Die Variante mit gewöhnlichen, unformatierten Textlinks meldet Kontrastbefunde an diesen Links; bei sichtbarem Nach-oben-Knopf zusätzlich einen Landmark-Befund. Das Archiv meldet außerdem Überschriftenreihenfolge und doppelte Landmark-Benennung. Alle Befunde betreffen dieselben Elemente wie in der Gegenprobe mit 1.8.0. Keine fehlerfreie Gesamt-Barrierefreiheit oder neuen Lighthouse-Werte behauptet. Keine Wiederholung der gesamten Editor-Bedienprüfung für diese Template-Entfernung.
|
||||
|
||||
**Veröffentlicht:** Commit `8f475d1` und Tag `v1.8.1` auf `origin`; [Release 1.8.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `fc5d7386d1d9b099e1506f16ab7937526014a85a96840c510e7ca4e2703206c3`. Über die Systemverwaltung aktualisieren; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.0 – Eigene 404-Seite mit Website-Design (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Einstellungen → 404-Seite mit Livesuche, bestehende Seite zuweisen, Auswahl zurücksetzen, direktem Bearbeiten und öffentlicher 404-Vorschau. „404-Vorlage anlegen“ erstellt und verknüpft einen bearbeitbaren Entwurf, ohne vorhandene Seiten zu überschreiben. Bis zur Veröffentlichung erscheint eine automatische Standard-404. Normaler Seitenrenderer übernimmt Standard-Header/-Footer, Theme, Schriften, Menüs und verknüpfte Sektionen. Die ID-Verknüpfung übersteht Umbenennen; Löschen führt auf den Standard zurück. Nur veröffentlichte Revisionen werden öffentlich ausgeliefert.
|
||||
@@ -10,7 +46,7 @@
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Laden, Veröffentlichung, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit. Alle 47 Twig-Dateien und der Produktionscontainer gültig; PHP-Stilprüfung über 133 Dateien ohne Befund.
|
||||
|
||||
**Veröffentlichung:** Release-Paket 1.8.0 wird aus dem geprüften Commit gebaut und auf dem bestehenden Git bereitgestellt.
|
||||
**Veröffentlicht:** Commit `16da844` und Tag `v1.8.0` auf `origin`; [Release 1.8.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4c90a0256ad827c40ae09db85fbe72a9315f41b181c26f9983d710b646bc64b4`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine neuen Lighthouse-Scores für eine absichtlich nicht indexierbare 404-Seite. Keine neuen Firefox-/Safari-/Screenreader- oder MySQL-Läufe; lokale Datenbankprüfungen verwenden SQLite. Nach Veröffentlichung über die Systemverwaltung aktualisieren; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs"
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/** A compact palette picker. Palette references stay references when saved. */
|
||||
let sequence = 0;
|
||||
const node = (tag, className, text) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.setAttribute('aria-controls', `${id}-choices`);
|
||||
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
|
||||
const ink = node('span', 'color-sample-ink'); sample.append(ink);
|
||||
const description = node('span', 'color-description');
|
||||
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
|
||||
description.append(name, detail);
|
||||
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
|
||||
trigger.append(sample, description, arrow);
|
||||
|
||||
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
|
||||
const heading = node('p', 'color-heading', 'Website-Farben');
|
||||
const palette = node('div', 'color-palette');
|
||||
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
|
||||
const buttons = [];
|
||||
let current = value, editing = false, mergeKey = `color-${++sequence}`;
|
||||
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
|
||||
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
|
||||
const restoreFocus = () => document.getElementById(id)?.focus();
|
||||
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
|
||||
for (const color of colors) {
|
||||
const button = node('button', 'color-option'); button.type = 'button';
|
||||
button.dataset.color = `@${color.key}`;
|
||||
button.title = `${color.name} · ${color.value}`;
|
||||
button.setAttribute('aria-label', button.title);
|
||||
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
button.append(swatch, node('span', 'color-option-name', color.name));
|
||||
button.addEventListener('click', () => choose(`@${color.key}`));
|
||||
buttons.push(button); palette.append(button);
|
||||
}
|
||||
if (colors.length) choices.append(heading, palette);
|
||||
|
||||
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
|
||||
const custom = node('div', 'color-custom');
|
||||
const picker = node('input', 'color-native'); picker.type = 'color';
|
||||
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
|
||||
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
|
||||
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
|
||||
text.setAttribute('aria-label', `Farbwert für ${label}`);
|
||||
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
|
||||
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
|
||||
custom.append(picker, text, accept);
|
||||
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
|
||||
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
|
||||
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
|
||||
clear.addEventListener('click', () => choose(''));
|
||||
choices.append(customLabel, custom, error, clear);
|
||||
wrap.append(trigger, choices);
|
||||
|
||||
function sync() {
|
||||
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
|
||||
ink.style.backgroundColor = hex || 'transparent';
|
||||
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
|
||||
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
|
||||
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
|
||||
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
|
||||
text.value = hex || current || '';
|
||||
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
|
||||
clear.disabled = current === '';
|
||||
}
|
||||
const finish = () => {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
onComplete();
|
||||
};
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!choices.hidden) {
|
||||
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
} else {
|
||||
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
|
||||
picker.addEventListener('input', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
editing = true; sync(); onChange(current, mergeKey);
|
||||
});
|
||||
picker.addEventListener('change', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
sync(); onChange(current, editing ? mergeKey : undefined);
|
||||
if (editing) finish();
|
||||
restoreFocus();
|
||||
});
|
||||
const applyText = () => {
|
||||
let raw = text.value.trim();
|
||||
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
|
||||
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
|
||||
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
|
||||
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
|
||||
text.focus(); return;
|
||||
}
|
||||
choose(raw);
|
||||
};
|
||||
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
|
||||
text.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
|
||||
});
|
||||
accept.addEventListener('click', applyText);
|
||||
wrap.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || choices.hidden) return;
|
||||
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
});
|
||||
// Commit after leaving the native picker even on browsers that omit its change event.
|
||||
wrap.addEventListener('focusout', () => setTimeout(() => {
|
||||
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
|
||||
}, 0));
|
||||
sync();
|
||||
return wrap;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -204,31 +205,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const colorControl = (id, value, apply) => {
|
||||
const wrap = element('div', 'color-control');
|
||||
const picker = document.createElement('input');
|
||||
picker.type = 'color';
|
||||
picker.id = id;
|
||||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
||||
const text = document.createElement('input');
|
||||
text.type = 'text';
|
||||
text.className = 'color-text';
|
||||
text.value = value ?? '';
|
||||
text.placeholder = '#1a2b3c';
|
||||
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
|
||||
const palette = document.createElement('select');
|
||||
palette.className = 'color-palette';
|
||||
palette.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
palette.append(new Option('Palette …', ''));
|
||||
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
|
||||
palette.value = (value ?? '').startsWith('@') ? value : '';
|
||||
|
||||
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
|
||||
text.addEventListener('change', () => apply(text.value.trim()));
|
||||
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
|
||||
wrap.append(picker, text, palette);
|
||||
return wrap;
|
||||
};
|
||||
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
|
||||
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||||
onComplete: () => {
|
||||
const focusedId = document.activeElement?.id;
|
||||
onChange();
|
||||
if (focusedId) document.getElementById(focusedId)?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
@@ -539,7 +523,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
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);
|
||||
@@ -552,9 +536,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
let hoverInherited = current.value || '';
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (name === deviceName) break;
|
||||
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
|
||||
}
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||||
: property.type === 'number'
|
||||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||||
|
||||
@@ -148,6 +148,8 @@ export function insertionTarget(document, selected, type) {
|
||||
}
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
|
||||
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
while (item) {
|
||||
const parentId = item.parent?.id ?? null;
|
||||
|
||||
@@ -110,6 +110,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
|
||||
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
|
||||
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
|
||||
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
|
||||
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
|
||||
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
|
||||
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
|
||||
.design-reset{margin-top:24px;white-space:normal;width:100%}
|
||||
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
|
||||
@@ -131,15 +134,39 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
|
||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
||||
.color-control .color-text{font-size:12px;min-height:38px}
|
||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
||||
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-trigger:hover{background:#f5f7f3}
|
||||
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
|
||||
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
|
||||
.color-description{display:grid;gap:3px;flex:1;min-width:0}
|
||||
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
|
||||
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
|
||||
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
|
||||
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
|
||||
.color-choices[hidden]{display:none}
|
||||
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
|
||||
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
|
||||
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.color-option:hover{background:#eef3ea;border-color:#718575}
|
||||
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
|
||||
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
|
||||
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.color-custom{display:flex;align-items:center;gap:6px}
|
||||
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
|
||||
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
|
||||
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
|
||||
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
|
||||
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
|
||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||
.length-control.is-inherited input{color:var(--muted)}
|
||||
|
||||
+58
-9
@@ -101,7 +101,7 @@ function controls() {
|
||||
function renderTree() {
|
||||
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
|
||||
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
|
||||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''} ${block.props?.title || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const build = blocks => {
|
||||
const list = document.createElement('ul'); list.className = 'structure-list';
|
||||
for (const block of blocks) {
|
||||
@@ -119,9 +119,10 @@ function renderTree() {
|
||||
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
|
||||
button.dataset.blockId = block.id;
|
||||
button.setAttribute('aria-pressed', String(block.id === selected));
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${block.props?.text || block.props?.alt || blockLabel(block)}`);
|
||||
const displayText = block.props?.text || block.props?.alt || block.props?.title || blockLabel(block);
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText}`);
|
||||
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block);
|
||||
const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || blockLabel(block);
|
||||
const text = document.createElement('span'); text.textContent = displayText;
|
||||
button.append(icon, text); item.append(button);
|
||||
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||||
list.append(item);
|
||||
@@ -323,12 +324,31 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||||
for (const [index, entry] of (block.children ?? []).entries()) {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'accordion-entry-choice';
|
||||
button.textContent = `${index + 1}. ${entry.props?.title || 'Ohne Titel'}`;
|
||||
button.setAttribute('aria-label', `Eintrag ${index + 1}: ${entry.props?.title || 'Ohne Titel'} bearbeiten`);
|
||||
button.addEventListener('click', () => { propertyPanel = 'content'; choose(entry.id); });
|
||||
entries.append(button);
|
||||
}
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||||
panel.append(hint, entries, add);
|
||||
}
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||||
add.textContent = '+ Inhalt hinzufügen';
|
||||
add.addEventListener('click', () => beginContainerInsert(block.id));
|
||||
panel.append(add);
|
||||
}
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
@@ -722,14 +742,16 @@ async function updatePreview() {
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
choose(id, false);
|
||||
if (insert.hasAttribute('data-editor-row')) {
|
||||
if (insert.hasAttribute('data-editor-add-entry')) {
|
||||
addAccordionEntry(id);
|
||||
} else if (insert.hasAttribute('data-editor-row')) {
|
||||
setPendingInsert(null);
|
||||
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
|
||||
propertyPanel = 'content'; changed();
|
||||
} else if (locate(history.document.blocks, id).block.type === 'row') {
|
||||
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
|
||||
changed();
|
||||
} else setPendingInsert({ parentId: id, index: 0 });
|
||||
} else beginContainerInsert(id);
|
||||
return;
|
||||
}
|
||||
if (inlineEdit) return;
|
||||
@@ -857,9 +879,28 @@ function addElement(type) {
|
||||
selected = history.change(doc => at
|
||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||
: insertBlock(doc, type, selected));
|
||||
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
|
||||
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
|
||||
changed();
|
||||
}
|
||||
|
||||
function beginContainerInsert(id) {
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (!block || busy) return;
|
||||
propertyPanel = 'content';
|
||||
choose(id, false);
|
||||
setPendingInsert({ parentId: id, index: block.children?.length ?? 0 });
|
||||
}
|
||||
|
||||
function addAccordionEntry(id) {
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (busy || block?.type !== 'accordion') return;
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => insertBlockAt(doc, 'accordionitem', id, block.children?.length ?? 0));
|
||||
collapsed.delete(id); propertyPanel = 'content'; changed();
|
||||
$('#property-title')?.focus();
|
||||
}
|
||||
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
@@ -945,7 +986,11 @@ function updateLibrary() {
|
||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||
: insertionTarget(history.document, selected, type);
|
||||
button.disabled = !target;
|
||||
if (!target) { button.title = whyNot(type); continue; }
|
||||
if (!target) {
|
||||
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
|
||||
? 'Wähle zuerst einen Akkordeon-Eintrag, um darin Inhalte einzufügen.' : whyNot(type);
|
||||
continue;
|
||||
}
|
||||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||||
}
|
||||
@@ -1092,7 +1137,11 @@ overlay = createCanvasOverlay({
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
if (id) {
|
||||
selected = payload.type === 'accordion' ? locate(history.document.blocks, id)?.block.children?.[0]?.id ?? id : id;
|
||||
if (['accordion', 'accordionitem'].includes(payload.type)) propertyPanel = 'content';
|
||||
changed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||||
|
||||
@@ -5,22 +5,28 @@ export function decorateContainers(preview, content) {
|
||||
const visit = (blocks, parent = null) => {
|
||||
blocks.forEach((block, index) => {
|
||||
const node = preview.getElementById(block.id);
|
||||
if (node && ['section', 'row', 'column'].includes(block.type)) {
|
||||
// Editing must expose the body without changing the visitor's saved open/closed state.
|
||||
if (node && block.type === 'accordionitem') node.open = true;
|
||||
if (node && ['section', 'row', 'column', 'accordion', 'accordionitem'].includes(block.type)) {
|
||||
const label = block.type === 'column' && parent?.type === 'row'
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : block.type === 'accordionitem'
|
||||
? `Eintrag „${block.props?.title || 'Ohne Titel'}“` : blockLabel(block);
|
||||
node.classList.add('editor-container');
|
||||
node.dataset.editorLabel = label;
|
||||
if (!block.children?.length) {
|
||||
node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
|
||||
if (!block.children?.length || ['accordion', 'accordionitem'].includes(block.type)) {
|
||||
const compact = Boolean(block.children?.length);
|
||||
if (!compact) node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner')
|
||||
: block.type === 'accordionitem' ? node.querySelector(':scope > .accordion-body') : node;
|
||||
if (!host) return;
|
||||
const empty = preview.createElement('div');
|
||||
empty.className = 'editor-empty';
|
||||
empty.className = compact ? 'editor-empty editor-add-content' : 'editor-empty';
|
||||
const name = preview.createElement('span');
|
||||
name.className = 'editor-empty-name'; name.textContent = label;
|
||||
const hint = preview.createElement('span');
|
||||
hint.className = 'editor-empty-hint';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen'
|
||||
: block.type === 'accordion' ? 'Eintrag hinzufügen und darin Inhalte gestalten' : 'Element hierher ziehen oder hinzufügen';
|
||||
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
|
||||
const add = (text, attribute, value, accessibleName) => {
|
||||
const button = preview.createElement('button');
|
||||
@@ -31,10 +37,15 @@ export function decorateContainers(preview, content) {
|
||||
actions.append(button);
|
||||
};
|
||||
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
|
||||
if (block.type === 'accordion') {
|
||||
actions.replaceChildren();
|
||||
add('+ Eintrag hinzufügen', 'data-editor-add-entry', '', 'Akkordeon: Eintrag hinzufügen');
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
|
||||
}
|
||||
empty.append(name, hint, actions);
|
||||
if (!compact) empty.append(name, hint);
|
||||
empty.append(actions);
|
||||
host.append(empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@
|
||||
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
|
||||
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@@ -162,12 +162,6 @@ body.consent-open{padding-bottom:260px}
|
||||
.site-footer .block-column{border:0;border-radius:0;padding:0}
|
||||
.site-header .block-text,
|
||||
.site-footer .block-text{margin:0}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
.legal-bar a{color:var(--accent);text-decoration:none}
|
||||
.legal-bar a:hover{text-decoration:underline}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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';
|
||||
|
||||
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/accordion'; 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();
|
||||
data.document.blocks.unshift({ id: 'qa-accordion', type: 'accordion', children: [
|
||||
{ id: 'qa-entry', type: 'accordionitem', props: { title: 'Was ist enthalten?', open: false }, children: [] },
|
||||
{ id: 'qa-open', type: 'accordionitem', props: { title: 'Schon geöffnet', open: true }, children: [{ id: 'qa-existing', type: 'text', props: { text: '<p>Vorhandener Inhalt bleibt.</p>' } }] },
|
||||
] }, { id: 'qa-empty-accordion', type: 'accordion', children: [] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const frame = page.frameLocator('#preview');
|
||||
const ready = async () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
const select = async id => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator('[data-panel="content"]').click(); };
|
||||
await ready();
|
||||
await frame.locator('#qa-entry > summary').click();
|
||||
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
|
||||
assert.equal(await page.locator('#save').isDisabled(), true, 'Revealing the editor body must not change the document');
|
||||
await frame.locator('#qa-entry [data-editor-insert]').focus();
|
||||
await frame.locator('#qa-entry [data-editor-insert]').press('Enter');
|
||||
assert.match(await page.locator('#insert-hint').innerText(), /Was ist enthalten/);
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await ready(); await frame.locator('#qa-entry .block-text').waitFor();
|
||||
const textId = await frame.locator('#qa-entry .block-text').getAttribute('id');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Text direkt im Akkordeon.'); await ready();
|
||||
assert.equal(await frame.locator('#qa-entry .block-text').innerText(), 'Text direkt im Akkordeon.');
|
||||
await page.locator('#undo').click(); await page.locator('#undo').click(); await ready();
|
||||
assert.equal(await frame.locator(`#${textId}`).count(), 0);
|
||||
await page.locator('#redo').click(); await page.locator('#redo').click(); await ready();
|
||||
assert.equal(await frame.locator(`#qa-entry #${textId}`).count(), 1);
|
||||
checks.push('closed entries reveal editable bodies and empty insert areas without changing public state; text insertion, editing and undo/redo');
|
||||
|
||||
await select('qa-accordion');
|
||||
assert.equal(await page.locator('#library [data-add="text"]').isDisabled(), true, 'Group selection must not put text outside the accordion');
|
||||
await page.getByRole('button', { name: 'Eintrag 1: Was ist enthalten? bearbeiten', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Inhalt hinzufügen', exact: true }).click();
|
||||
await page.locator('#library [data-add="button"]').click(); await ready();
|
||||
const firstButtonId = await frame.locator('#qa-entry a.button').getAttribute('id');
|
||||
assert.equal(await frame.locator('#qa-entry .accordion-body > [id]').last().getAttribute('id'), firstButtonId, 'Add content appends after existing content');
|
||||
await select('qa-open');
|
||||
await page.locator('#library [data-add="button"]').dragTo(frame.locator('#qa-open .accordion-body'), { targetPosition: { x: 40, y: 20 } });
|
||||
await ready(); await frame.locator('#qa-open a.button').waitFor();
|
||||
const draggedId = await frame.locator('#qa-open a.button').getAttribute('id');
|
||||
await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${draggedId}`).count(), 0);
|
||||
await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-open #${draggedId}`).count(), 1);
|
||||
checks.push('entry selection in inspector, append content and drag/drop into an open body with undo/redo');
|
||||
|
||||
await frame.locator('#qa-accordion > .editor-add-content [data-editor-add-entry]').click();
|
||||
await page.getByLabel('Titel', { exact: true }).fill('Ein neuer Eintrag'); await ready();
|
||||
const newEntryId = await frame.locator('#qa-accordion > details').last().getAttribute('id');
|
||||
assert.equal(await frame.locator('#qa-accordion > details').count(), 3);
|
||||
assert.equal(await frame.locator(`#${newEntryId}`).evaluate(el => el.open), true);
|
||||
await select('qa-empty-accordion');
|
||||
await page.getByRole('button', { name: '+ Eintrag hinzufügen', exact: true }).click(); await ready();
|
||||
assert.equal(await frame.locator('#qa-empty-accordion > details').count(), 1);
|
||||
await page.getByLabel('Titel', { exact: true }).fill('Erster Eintrag'); await ready();
|
||||
await page.locator('#tree-search').fill('Ein neuer Eintrag');
|
||||
assert.equal(await page.locator(`#structure [data-block-id="${newEntryId}"]`).count(), 1);
|
||||
await page.locator('#tree-search').fill('');
|
||||
await select('qa-entry'); await page.locator('[data-panel="design"]').click();
|
||||
await page.locator('#library [data-add="accordion"]').click(); await ready();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Akkordeon-Eintrag');
|
||||
assert.equal(await page.getByLabel('Titel', { exact: true }).inputValue(), 'Eine Frage');
|
||||
await page.locator('#undo').click(); await ready();
|
||||
checks.push('add entries in canvas and inspector, recover empty accordion, search by title, new accordion starts with its editable entry selected');
|
||||
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await ready(); await select('qa-entry');
|
||||
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
|
||||
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
const saved = await (await context.request.get(api)).json();
|
||||
const accordion = saved.document.blocks.find(b => b.id === 'qa-accordion');
|
||||
assert.equal(accordion.children[0].props.open, false); assert.equal(accordion.children[1].props.open, true);
|
||||
assert.deepEqual(accordion.children[0].children.map(b => b.id), [textId, firstButtonId]);
|
||||
assert.ok(!JSON.stringify(saved.document).includes('editor-empty'));
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false });
|
||||
const visitor = await plain.newPage(); await visitor.goto(origin);
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
|
||||
assert.equal(await visitor.locator('#qa-open').evaluate(el => el.open), true);
|
||||
assert.equal(await visitor.locator('[data-editor-insert],[data-editor-add-entry],.editor-empty').count(), 0);
|
||||
await visitor.locator('#qa-entry > summary').click();
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
assert.equal(await visitor.locator(`#qa-entry #${textId}`).isVisible(), true);
|
||||
await visitor.locator('#qa-entry > summary').press('Enter');
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
|
||||
await plain.close();
|
||||
checks.push('save/reload/publication keep children and original open flags; published accordion toggles without JavaScript and contains no editing aids');
|
||||
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1050 }); await select('qa-entry'); await ready();
|
||||
await page.locator('[data-viewport="mobile"]').click(); await ready();
|
||||
await frame.locator('#qa-entry').scrollIntoViewIfNeeded();
|
||||
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) })), []);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
await page.screenshot({ path: `${report}/editor-${width}.png`, fullPage: true });
|
||||
}
|
||||
checks.push('desktop/mobile editor passes axe and layout checks with editable accordion preview');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Accordion 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(); }
|
||||
@@ -0,0 +1,147 @@
|
||||
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';
|
||||
|
||||
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/color-picker'; 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 schema = await (await context.request.get(origin + '/admin/api/design')).json();
|
||||
const deep = schema.colors.find(c => c.key === 'deep');
|
||||
const accent = schema.colors.find(c => c.key === 'accent');
|
||||
assert.ok(deep && accent);
|
||||
data.document.blocks.unshift({ id: 'qa-color', type: 'section', style: { desktop: {
|
||||
backgroundColor: '@deep', color: '#ffffff', gradientFrom: '#11223344', gradientTo: '#55667788',
|
||||
hover: { backgroundColor: '@accent' }, padding: { top: '40px', bottom: '40px' },
|
||||
} }, children: [{ id: 'qa-color-text', type: 'text', props: { text: '<p>Farben direkt im Blick</p>' } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => {
|
||||
await page.locator('#structure [data-block-id="qa-color"]').click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Hintergrund$/ }) });
|
||||
if (!(await group.getAttribute('open'))) {
|
||||
if (!(await group.evaluate(el => el.open))) await group.locator('summary').click();
|
||||
}
|
||||
};
|
||||
await select();
|
||||
const trigger = (key = 'backgroundColor') => page.locator(`#style-${key}`);
|
||||
const choices = (key = 'backgroundColor') => page.locator(`#style-${key}-choices`);
|
||||
const sample = async (hex, key = 'backgroundColor') => {
|
||||
await page.waitForFunction(({ hex, key }) => document.querySelector(`#style-${key} .color-detail`)?.textContent.includes(hex), { hex, key });
|
||||
assert.ok(await trigger(key).locator('.color-sample-ink').evaluate(el => el.style.backgroundColor !== '' && el.style.backgroundColor !== 'transparent'));
|
||||
};
|
||||
const choose = async (token, key = 'backgroundColor') => { await trigger(key).click(); await choices(key).locator(`[data-color="${token}"]`).click(); };
|
||||
const frameColor = async hex => {
|
||||
const rgb = `rgb(${[1,3,5].map(i => parseInt(hex.slice(i,i+2),16)).join(', ')})`;
|
||||
await page.waitForFunction(rgb => {
|
||||
const doc = document.querySelector('#preview').contentDocument, block = doc?.getElementById('qa-color');
|
||||
return block && doc.defaultView.getComputedStyle(block).backgroundColor === rgb;
|
||||
}, rgb);
|
||||
};
|
||||
await sample(deep.value); assert.match(await trigger().innerText(), new RegExp(deep.name));
|
||||
await trigger().focus(); await page.keyboard.press('Enter');
|
||||
assert.equal(await trigger().getAttribute('aria-expanded'), 'true');
|
||||
await page.keyboard.press('Tab'); assert.equal(await choices().locator('[data-color]').first().evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Escape'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
|
||||
await choose('@accent'); await sample(accent.value); await frameColor(accent.value);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await page.locator('#redo').click(); await sample(accent.value);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
checks.push('palette name and real token color, keyboard open/tab/escape, selection, canvas, undo and redo');
|
||||
|
||||
await page.locator('[data-viewport="tablet"]').click(); await sample(deep.value);
|
||||
assert.match(await trigger().innerText(), /Geerbt/);
|
||||
await sample(accent.value, 'backgroundColor-hover');
|
||||
await choose('@accent'); await page.locator('[data-viewport="mobile"]').click(); await sample(accent.value);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Hintergrundfarbe zurücksetzen', exact: true }).click(); await sample(deep.value);
|
||||
await page.locator('[data-viewport="desktop"]').click();
|
||||
checks.push('desktop/tablet/mobile inheritance including hover colors and reset');
|
||||
|
||||
await trigger().click();
|
||||
await choices().locator('.color-native').evaluate(el => {
|
||||
window.colorPickerNode = el;
|
||||
el.focus();
|
||||
for (const value of ['#112233', '#223344', '#334455']) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
});
|
||||
assert.equal(await choices().locator('.color-native').evaluate(el => el === window.colorPickerNode), true);
|
||||
await frameColor('#334455');
|
||||
await choices().locator('.color-native').dispatchEvent('change');
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger().click();
|
||||
await choices().locator('.color-native').evaluate(el => { el.value = '#445566'; el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await sample('#445566'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger().click();
|
||||
const hex = page.locator('#style-backgroundColor-hex');
|
||||
await hex.fill('ungueltig'); await hex.press('Enter'); assert.equal(await hex.getAttribute('aria-invalid'), 'true');
|
||||
await sample(deep.value);
|
||||
await hex.fill('#abc'); await hex.press('Enter'); await sample('#aabbcc'); await frameColor('#aabbcc');
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger('gradientFrom').click();
|
||||
await choices('gradientFrom').locator('.color-native').evaluate(el => { el.value = '#224466'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await sample('#22446644', 'gradientFrom');
|
||||
await page.locator('#undo').click(); await sample('#11223344', 'gradientFrom');
|
||||
checks.push('native live color changes retain DOM and form one undo step; invalid input refused; short hex expanded; gradient alpha preserved');
|
||||
|
||||
await choose('@accent');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await select(); await sample(accent.value);
|
||||
const saved = await (await context.request.get(api)).json();
|
||||
assert.equal(saved.document.blocks[0].style.desktop.backgroundColor, '@accent');
|
||||
assert.equal(saved.document.blocks[0].style.desktop.gradientFrom, '#11223344');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const visitor = await context.newPage(); await visitor.goto(origin);
|
||||
assert.equal(await visitor.locator('#qa-color').evaluate(el => getComputedStyle(el).backgroundColor), `rgb(${[1,3,5].map(i => parseInt(accent.value.slice(i,i+2),16)).join(', ')})`);
|
||||
await visitor.close();
|
||||
checks.push('save, reload and publication preserve palette references and alpha values');
|
||||
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1050 });
|
||||
if (await trigger().getAttribute('aria-expanded') === 'false') await trigger().click();
|
||||
await choices().scrollIntoViewIfNeeded();
|
||||
await trigger().evaluate(el => {
|
||||
const panel = el.closest('.right-panel');
|
||||
panel.scrollTop += el.getBoundingClientRect().top - panel.getBoundingClientRect().top - 16;
|
||||
});
|
||||
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) })), []);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
assert.equal(await choices().evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('.right-panel').screenshot({ path: `${report}/palette-${width}.png` });
|
||||
}
|
||||
checks.push('open palette and editor pass axe and fit desktop/mobile without horizontal overflow');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Color picker 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(); }
|
||||
@@ -487,7 +487,8 @@ try {
|
||||
await frameValue('#headline', 'fontSize', '72px');
|
||||
|
||||
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
|
||||
await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link');
|
||||
await page.locator('#style-color').click();
|
||||
await page.locator('#style-color-choices [data-color="@link"]').click();
|
||||
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
|
||||
const contrast = page.locator('.contrast-note');
|
||||
await contrast.waitFor();
|
||||
@@ -572,9 +573,10 @@ try {
|
||||
// The background group is collapsed by default, and the readable field next to the swatch is
|
||||
// what a person types into.
|
||||
await openGroup('Hintergrund');
|
||||
const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]');
|
||||
await page.locator('#style-backgroundColor-hover').click();
|
||||
const hoverColor = page.locator('#style-backgroundColor-hover-hex');
|
||||
await hoverColor.fill('#112233');
|
||||
await hoverColor.blur();
|
||||
await hoverColor.press('Enter');
|
||||
// The preview carries its stylesheet as template text, applied by the editor as a constructed
|
||||
// stylesheet; the hover rule has to arrive through that path, not as a style element.
|
||||
await page.waitForFunction(() => {
|
||||
@@ -857,7 +859,7 @@ try {
|
||||
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
||||
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
|
||||
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
|
||||
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
|
||||
assert.equal(await visitorPage.locator('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
|
||||
// And the way back: the consent page can refuse again, which blocks the embed once more.
|
||||
await visitorPage.goto(origin + '/einwilligung');
|
||||
|
||||
@@ -131,11 +131,6 @@ final class PageController extends AbstractController
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
|
||||
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
|
||||
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
|
||||
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|
||||
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
|
||||
], new Response(status: $status));
|
||||
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
|
||||
@@ -53,17 +53,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · KantSites</title><link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">{% include 'favicon.html.twig' %}</head>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="{{ consentOpen ? 'consent-open' : '' }}"><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main>
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -91,18 +91,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{# Nur, wenn die Seite die Zeile nicht selbst enthaelt: sonst stuende sie zweimal da. #}
|
||||
{% if (legal is not empty or revocable) and not hasLegalBlock|default(false) %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% set oben = siteSettings.load() %}
|
||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||
|
||||
+12
-11
@@ -1118,12 +1118,9 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testTheLegalLineCanLiveInsideTheFooterInsteadOfUnderneathIt(): void
|
||||
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
|
||||
{
|
||||
// Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser
|
||||
// lösen." Die eingebaute Leiste klebte als heller Streifen unter einem dunklen Fuß. Als
|
||||
// Baustein steht sie dort, wo sie hingehört, und erbt die Farben ihres Bereichs.
|
||||
// Zwei verschiedene Seiten: die Zeile fuehrt zu Datenschutz und Impressum.
|
||||
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||||
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
|
||||
$seite = $this->pages->findForEditor($datenschutz);
|
||||
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
|
||||
@@ -1132,9 +1129,10 @@ final class CmsTest extends TestCase
|
||||
$document['schemaVersion'] = 19;
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
// Ohne Baustein: die eingebaute Leiste am Seitenende.
|
||||
// Ohne selbst platzierte Links bleibt die Seite ohne Rechtszeile.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<nav class="legal-bar"', $html);
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $html);
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">', $html);
|
||||
|
||||
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
|
||||
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
|
||||
@@ -1142,7 +1140,7 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||||
$mit = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit, 'Zweimal dieselbe Zeile ist schlechter als einmal.');
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit);
|
||||
self::assertStringContainsString('Datenschutz', $mit);
|
||||
}
|
||||
|
||||
@@ -3610,12 +3608,12 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
self::assertSame('', $this->site->load()[$key]);
|
||||
}
|
||||
// A page that exists is accepted and then linked on every page.
|
||||
// A page that exists is accepted; placing its link remains an editorial choice.
|
||||
$this->publishedPage('datenschutz', 'Datenschutz');
|
||||
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
||||
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
|
||||
@@ -3650,7 +3648,10 @@ final class CmsTest extends TestCase
|
||||
|
||||
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][] = ['id' => 'consent-links', 'type' => 'legal', 'props' => []];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$this->askConsent();
|
||||
|
||||
$response = $this->request('/');
|
||||
|
||||
@@ -718,7 +718,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertResponseRedirects('/admin/media?q=0');
|
||||
}
|
||||
|
||||
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
||||
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
@@ -745,11 +745,11 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
// And they are linked from an ordinary page, without anyone editing the footer.
|
||||
// Creating the pages does not alter the author's footer or append a link bar.
|
||||
$client->request('GET', '/');
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringNotContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
|
||||
// Running it twice keeps the existing pages: they may already carry the real text.
|
||||
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
|
||||
|
||||
@@ -224,6 +224,17 @@ test('v6 containers accept only their own children and an accordion starts with
|
||||
assert.equal(canContain(block, { type: 'accordionitem' }), true);
|
||||
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
|
||||
assert.equal(canContain(block.children[0], { type: 'text' }), true);
|
||||
// Selecting the group must not silently put a text outside it. Choose the entry first.
|
||||
assert.equal(insertionTarget(history.document, accordion, 'text'), null);
|
||||
assert.equal(insertBlock(history.document, 'text', accordion), null);
|
||||
const entry = block.children[0].id;
|
||||
const text = history.change(doc => insertBlock(doc, 'text', entry));
|
||||
assert.equal(locate(history.document.blocks, text).parent.id, entry);
|
||||
history.undo();
|
||||
assert.equal(locate(history.document.blocks, entry).block.children.length, 0);
|
||||
history.redo();
|
||||
assert.equal(locate(history.document.blocks, text).parent.id, entry);
|
||||
history.undo();
|
||||
|
||||
// A logo strip takes images only, like a gallery.
|
||||
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
|
||||
|
||||
Reference in new issue
Block a user