Compare commits

...
3 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 9726af9254 Fassung 1.5.3: der Editor blieb nach dem Update schwarz
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
Nach 1.5.2 meldete der Editor im Browser "The requested module
'./editor-model.js' does not provide an export named 'dropList'", und der
Werkzeugkasten war leer.

Ursache war nicht der Code, sondern das Zwischenspeichern. public/.htaccess
setzt fuer jede .js-Datei Cache-Control: immutable, max-age=31536000 - ein
Jahr. Die Fassungskennung haengt aber nur an den Einstiegsdateien, die die
Vorlage schreibt. Die fuenf Module, die der Editor ueber import './...js'
laedt, tragen keine: der Browser holte das neue editor.js und behielt das alte
editor-model.js. Ein neues canvas-overlay.js traf damit auf ein Modell ohne
dropList.

Beide gemeldeten Symptome hatten diese eine Wurzel. Seit 1.5.1 baut das Skript
den Werkzeugkasten auf, und editor.js importiert canvas-overlay.js in Zeile 5;
bricht der Import, laeuft keine Zeile danach und die Elementliste bleibt leer.
Das gemeldete fehlende Menue war der Werkzeugkasten, nicht die
Verwaltungsnavigation - die wurde geprueft und ist in Chrome 152 in Ordnung.

Behebung: eine Importkarte in base.html.twig haengt die Fassung an die
aufgeloeste Adresse jedes Moduls, ohne dass eine Importzeile sie kennen muss.
Das Zwischenspeichern ueber ein Jahr bleibt - die Leistung war nie das
Problem, nur die fehlende Kennung.

Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe.
Unter script-src 'self' wurde sie wortlos verworfen. AdminHeadersSubscriber
erzeugt deshalb je Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie
und legt ihn in die Anfrage; csp_nonce() gibt ihn der Karte mit. Der Nonce
erlaubt genau dieses eine Skript, 'self' gilt unveraendert fuer alles andere.

Geprueft: der Browsertest verlangt an jeder angeforderten .js-Adresse eine
Fassungskennung (vorher fehlten fuenf, jetzt tragen alle acht), dazu ein Test,
der jeden Modulimport gegen die Importkarte haelt - Gegenprobe gemacht.
npm test 28/28, phpunit 452/452, npm run test:editor vollstaendig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:00:52 +02:00
kantarciandClaude Opus 5 7225ae052e Fassung 1.5.2: die Optionen selbst
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
Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten.

Geraeteschalter je Option. Bisher entschied ein einziger Schalter ueber der
Vorschau, welches Geraet das ganze Bedienfeld bearbeitet; "ist die Ueberschrift
auf dem Telefon kleiner" liess sich nur durch Umschalten und Hinsehen
beantworten. Jetzt traegt jede Option drei Chips (D, T, M): sie zeigen, wo ein
eigener Wert liegt, und ein Klick wechselt dorthin. Bedienfeld und Vorschau
wechseln gemeinsam - einen Wert fuer ein Geraet zu setzen, das man nicht
ansieht, ist blindes Aendern.

Abstand als Rahmen. Vier Felder untereinander, beschriftet "Oben, Rechts,
Unten, Links", zwangen zum Lesen. Jetzt sitzt jedes Feld an seiner Kante, die
Lage ist die Beschriftung; in der Mitte verkettet ein Schalter alle vier. Er
startet aus: er ersetzt den frueheren Knopf "Alle Seiten gleich setzen", und
das war eine bewusste Handlung. Seiten mit unterschiedlichen Einheiten gelten
als "gemischt" und bleiben unangetastet - ein gespeichertes 2rem wird nicht
still zu 2px.

Ziehfelder. Zahlenfelder lassen sich seitwaerts ziehen, Umschalt grob, Alt
fein. Tippen und Pfeiltasten bleiben unveraendert. Nur fuer Maus und Stift:
auf einem Tippgeraet ist ein seitliches Ziehen ueber ein Feld die Art, wie das
Bedienfeld gescrollt wird.

Dabei behoben: der Zug war nach einem Pixel zu Ende, weil jede Aenderung das
Bedienfeld neu zeichnete und damit das Feld ersetzte, an dem der Zeiger hing
(keepPanel). Der Kettenschalter verlor aus demselben Grund seinen Zustand. Und
das Gleichziehen nahm den Wert des ersten Feldes, auch wenn es leer war - ein
Druck auf die Kette loeschte damit die Seiten, die einen Wert hatten.

Geprueft: npm test 27/27, phpunit 452/452, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:34:37 +02:00
kantarciandClaude Opus 5 0e2334d30f Fassung 1.5.1: der Werkzeugkasten kennt den Zusammenhang
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
Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als waeren sie
Seitenanfaenge. Sie gehoeren in den Inhalt - erst eine Sektion, dann eine Zeile
darin. Das war nicht nur eine Anzeigefrage: eine Zeile nahm bisher jedes Kind
an, eine Ueberschrift konnte neben den Spalten landen, und eine Spalte durfte
ueberall stehen. Die Struktur, die der Editor in seinem eigenen Hinweistext
beschreibt, galt fuer die Schaltflaechen nicht.

Uebernommen ist Flatsomes Regelmodell (Elements.php): ALLOW - eine Zeile nimmt
nur Spalten; REQUIRE - eine Spalte lebt nur in einer Zeile. Dazu ein Zusatz,
den Flatsome nicht hat: ROOT_NEEDS_CONTAINER - eine Zeile ist kein
Seitenanfang. In jedem Behaelter bleibt sie erlaubt, auch in einem Tab.

Daraus die Bedienung:

- Werkzeugkasten nach Aufbau gruppiert statt flacher Liste aus 31 Typen.
- Was nicht hingehoert, ist gesperrt und sagt beim Ueberfahren warum. Eine
  Regel, die man erklaert bekommt, lernt man; eine Schaltflaeche, die nur
  nicht mehr reagiert, drueckt man weiter.
- Was moeglich ist, sagt wohin es kommt.
- Die Zeile wird nach Spaltenzahl angeboten und bringt ihre Spalten mit; der
  separate Zeilenbereich ist entfallen.
- insertionTarget beantwortet "wohin kaeme das?", ohne etwas einzufuegen.
  Knopf, Einfuegepunkt, Ziehen und die Sperrlogik nutzen dieselbe Funktion -
  vorher entschied insertBlock allein, und insertRow hatte eine zweite Kopie
  derselben Platzierungslogik.

Ein Element, das der gewaehlte Behaelter nicht nimmt, wird nicht gesperrt,
sondern steigt auf, bis ein Elternteil es annimmt.

Nebenbei behoben: postnav fehlte seit Schema v18 in labels und icons, der
Strukturbaum zeigte dafuer "Unbekannt" und ein Fragezeichen.

Geprueft: npm test 27/27, phpunit 446/446, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:13:25 +02:00
12 changed files with 939 additions and 121 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.5.0
1.5.3
+148 -4
View File
@@ -1,6 +1,6 @@
# Projektstatus
Stand: 14.09.2026, Fassung **1.5.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
Stand: 14.09.2026, Fassung **1.5.3**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
@@ -265,11 +265,155 @@ der Server. Die Werkzeugleiste ist nicht mit einem Screenreader erprobt, nur mit
lassen sich noch nicht in der Vorschau breiter ziehen, der Werkzeugkasten ist weiterhin eine
Textliste ohne Vorschaubilder, und es gibt je Option noch keinen eigenen Geräteschalter.
## Arbeitspaket H, Teil 2: der Werkzeugkasten kennt den Zusammenhang
**Erledigt.** Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als wären sie
Seitenanfänge. Sie gehören in den Inhalt – erst eine Sektion, dann eine Zeile darin. Das war
nicht nur eine Anzeigefrage: eine Zeile nahm bisher **jedes** Kind an, eine Überschrift konnte
neben den Spalten landen, und eine Spalte durfte überall stehen. Die Struktur, die der Editor in
seinem eigenen Hinweistext beschreibt – Sektion → Zeile → Spalte → Inhalt –, galt für die
Schaltflächen nicht.
Übernommen ist Flatsomes Regelmodell, weil es die Fälle abdeckt und weil es das ist, was Umut in
den Händen hat (`inc/builder/core/server/src/Collections/Elements.php`):
| Regel | Flatsome | KantSites jetzt |
| --- | --- | --- |
| `allow` | `row => ['col']`, `accordion => ['accordion-item']` | `ALLOW` – Zeile nimmt nur Spalten |
| `require` | `col => ['row']`, `tab => ['tabgroup']` | `REQUIRE` – Spalte lebt nur in einer Zeile |
| — | (Flatsome kennt das nicht) | `ROOT_NEEDS_CONTAINER` – eine Zeile ist kein Seitenanfang |
Der dritte Punkt ist der Zusatz: Flatsome lässt eine Zeile auch lose am Dokumentanfang zu. Genau
das ist das „aus dem Nichts“, das Umut stört, also wird es unterbunden – in jedem Behälter
dagegen bleibt die Zeile erlaubt, auch in einem Tab oder einem Akkordeon-Eintrag.
Daraus folgt die Bedienung:
- **Der Werkzeugkasten ist nach Aufbau gruppiert** – Aufbau, Inhalt, Bausteine, Gruppen, Seite
und Daten – statt einer flachen Liste aus 31 Typen.
- **Was an der gewählten Stelle nicht hingehört, ist gesperrt und sagt beim Überfahren warum**
(„Spalte: gehört in Zeile. Wähle dort zuerst ein Element aus.“). Eine Regel, die man erklärt
bekommt, lernt man; eine Schaltfläche, die nur nicht mehr reagiert, drückt man weiter.
- **Was möglich ist, sagt wohin es kommt** („Wird in Zeile eingefügt.“).
- **Die Zeile wird nach Spaltenzahl angeboten** (2, 3, 4) und bringt ihre Spalten mit. Der
separate Zeilenbereich über der Liste ist entfallen.
- **Eine Regel für alle Wege:** `insertionTarget` beantwortet „wohin käme das?“, ohne etwas
einzufügen. Knopf, Einfügepunkt, Ziehen und die Sperrlogik des Werkzeugkastens nutzen
dieselbe Funktion – vorher entschied `insertBlock` allein, niemand konnte vorher fragen, und
`insertRow` hatte eine eigene zweite Kopie derselben Platzierungslogik.
Ein Element, das der gewählte Behälter nicht nimmt, wird **nicht** gesperrt, sondern steigt auf,
bis ein Elternteil es annimmt: eine Überschrift bei gewählter Zeile landet hinter der Zeile in
der Sektion. Die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
**Nebenbei behoben:** `postnav` fehlte seit Schema v18 in `labels` und `icons` – der
Strukturbaum zeigte für die Beitragsnavigation „Unbekannt“ und ein Fragezeichen.
**Geprüft.** `npm test` 27 von 27 (fünf neue: Zeilen-/Spaltenregel, oberste Ebene, Übereinstimmung
von Auskunft und Tat, Kategorie-Vollständigkeit, Zeile mit Spalten an fester Stelle).
`php vendor/bin/phpunit` 446 von 446. `npm run test:editor` vollständig mit neuem Abschnitt
„the toolbox follows the structure“; alle axe-Prüfungen ohne Verstoß. Screenshot unter
`reports/editor/toolbox.png`.
**Bekannte Grenzen.** Inhalt darf weiterhin lose am Dokumentanfang stehen – der Validator
erlaubt es, und eine weitere Einschränkung wäre erfunden statt übernommen. Bestehende Dokumente
werden nicht nachträglich geprüft: eine Überschrift, die früher direkt in einer Zeile gelandet
ist, wird weiterhin ausgegeben und lässt sich auswählen und löschen, nur nicht mehr dorthin
verschieben. Der Werkzeugkasten hat weiterhin keine Vorschaubilder und keine Suche.
## Arbeitspaket H, Teil 3a: die Optionen selbst
**Erledigt.** Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten.
**Geräteschalter je Option.** Bisher entschied ein einziger Schalter über der Vorschau, welches
Gerät das ganze Bedienfeld bearbeitet. „Ist die Überschrift auf dem Telefon kleiner?“ ließ sich
nur durch Umschalten und Hinsehen beantworten. Jetzt trägt jede Option drei Chips (D, T, M): sie
zeigen, **wo ein eigener Wert liegt**, und ein Klick wechselt dorthin. Das ist mehr als Flatsome
bietet – dort schaltet man um, hier sieht man vorher, ob es überhaupt etwas zu sehen gibt.
Bedienfeld und Vorschau wechseln gemeinsam: einen Wert für ein Gerät zu setzen, das man nicht
ansieht, ist blindes Ändern.
**Abstand als Rahmen.** Innenabstand, Außenabstand, Rahmenstärke und Ecken standen als vier
Felder untereinander, beschriftet „Oben, Rechts, Unten, Links“ – man musste lesen, um zu wissen,
welche Kante man setzt. Jetzt sitzt jedes Feld an seiner Kante, die Lage ist die Beschriftung.
In der Mitte verkettet ein Schalter alle vier. Er startet **aus**: er ersetzt den früheren Knopf
„Alle Seiten gleich setzen“, und das war eine bewusste Handlung. Die Einheit bleibt eine Wahl für
die ganze Eigenschaft – vier Einheitenmenüs passen nicht in ein 240 Pixel breites Feld. Seiten
mit unterschiedlichen Einheiten werden als „gemischt“ geführt und bleiben unangetastet, bis eine
Einheit tatsächlich gewählt wird; ein gespeichertes 2rem wird nicht still zu 2px.
**Ziehfelder.** Zahlenfelder lassen sich seitwärts ziehen. „24“ tippen, schauen, „26“ tippen,
schauen, „22“ tippen ist drei Anläufe für eine Entscheidung; Ziehen beantwortet sie in einer
Bewegung. Umschalt grob, Alt fein. Tippen und Pfeiltasten bleiben unverändert. Nur für Maus und
Stift: auf einem Tippgerät ist ein seitliches Ziehen über ein Feld die Art, wie das Bedienfeld
gescrollt wird.
**Dabei gefunden und behoben:** Der Zug war zunächst nach einem Pixel zu Ende – jede Änderung
zeichnete das Bedienfeld neu und ersetzte damit genau das Feld, an dem der Zeiger hing. Das
Bedienfeld bleibt während eines Zuges stehen und wird einmal neu gezeichnet, wenn losgelassen
wird (`keepPanel`). Der Kettenschalter verlor aus demselben Grund seinen Zustand, sobald er
gedrückt wurde; er liegt jetzt außerhalb der Darstellung. Und das Gleichziehen nahm den Wert des
ersten Feldes, auch wenn es leer war – ein Druck auf die Kette löschte damit die Seiten, die
einen Wert hatten. Es nimmt jetzt den ersten vorhandenen Wert.
**Geprüft.** `npm test` 27 von 27. `php vendor/bin/phpunit` 452 von 452. `npm run test:editor`
vollständig mit neuem Abschnitt „device chips, spacing box and scrub fields“: Chips zeigen den
richtigen Zustand und wechseln Bedienfeld und Vorschau zusammen, der Rahmen ändert ohne Kette
nur eine Seite und mit Kette alle vier, die Einheitenumstellung erhält die Zahlen, und ein Zug
über 20 Pixel hebt 72 auf 92 und ist **ein** Undo-Schritt. Alle axe-Prüfungen ohne Verstoß.
Screenshot unter `reports/editor/options.png`.
**Bekannte Grenzen.** Die Chips zeigen einen eigenen Wert, aber nicht, *welchen* – dafür muss
man weiterhin hinwechseln. Auf Tippgeräten gibt es kein Ziehen. Der Rahmen zeigt geerbte Werte
als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vorschaubilder und
Suche im Werkzeugkasten, Vorlagen je Element mit Bild, und das Ziehen der Spaltenbreite in der
Vorschau.
## 1.5.3: der Editor blieb nach dem Update schwarz
**Der Fehler.** Nach 1.5.2 meldete der Editor im Browser
`The requested module './editor-model.js' does not provide an export named 'dropList'`, und der
Werkzeugkasten war leer.
Ursache war nicht der Code, sondern das Zwischenspeichern. `public/.htaccess` setzt für **jede**
`.js`-Datei `Cache-Control: public, max-age=31536000, immutable` – ein Jahr. Die Fassungskennung
`?v=` hängt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fünf Module, die der
Editor über `import './…​.js'` lädt, tragen keine: der Browser holte das neue `editor.js` und
behielt das alte `editor-model.js`. Ein neues `canvas-overlay.js` traf damit auf ein Modell ohne
`dropList`.
Beide gemeldeten Symptome hatten diese eine Wurzel: seit 1.5.1 baut das Skript den Werkzeugkasten
auf (`buildLibrary()`), und `editor.js` importiert `canvas-overlay.js` in Zeile 5. Bricht der
Import, läuft keine Zeile danach – und die Elementliste bleibt leer. Das gemeldete „Menü fehlt“
war der Werkzeugkasten, nicht die Verwaltungsnavigation. Die wurde geprüft: in Chrome 152 stehen
`::details-content`, ausgeblendeter Schalter und sichtbare Liste korrekt.
**Die Behebung.** Eine Importkarte in `base.html.twig` hängt die Fassung an die aufgelöste
Adresse jedes Moduls, ohne dass eine einzige Importzeile sie kennen muss. Das behält das
Zwischenspeichern über ein Jahr – die Leistung war nie das Problem, nur die fehlende Kennung.
Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter
`script-src 'self'` wurde sie wortlos verworfen. `AdminHeadersSubscriber` erzeugt deshalb je
Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; die
Twig-Funktion `csp_nonce()` gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript –
`'self'` gilt unverändert für alles andere.
**Geprüft.** Der Browsertest schreibt jede angeforderte `.js`-Adresse mit und verlangt an jeder
eine Fassungskennung; vor der Behebung nennt er die fünf fehlenden beim Namen, danach trägt sie
alle acht. Dazu ein Test, der jeden `./…​.js`-Import in `public/assets` gegen die Importkarte
hält – ein neues Modul, das dort fehlt, fällt damit im Test auf und nicht erst beim nächsten
Update beim Kunden. Gegenprobe gemacht: mit einem entfernten Eintrag schlägt er fehl.
`npm test` 28 von 28, `php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig.
**Bekannte Grenze.** Ein Modulfehler legt den Editor weiterhin vollständig lahm, und der
Werkzeugkasten ist seit 1.5.1 nichts ohne sein Skript. Für einen Editor ist das vertretbar – die
öffentliche Website läuft ohne JavaScript –, aber die Fehleranzeige dafür ist noch die
Browserkonsole und kein Satz in der Oberfläche.
## Nächster konkreter Schritt
Arbeitspaket H, Teil 2: den Werkzeugkasten auf ein Bilderraster mit Suche und Kategorien
umstellen, Vorlagen je Element mit Vorschaubild, Geräteschalter an der einzelnen Option,
Ziehfelder für Zahlen, Abstandsrahmen und das Ziehen der Spaltenbreite in der Vorschau. Danach
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
Vorschaubild und das Ziehen der Spaltenbreite in der Vorschau. Danach
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
Begründung in `docs/FLATSOME-ABGLEICH.md`.
+225 -33
View File
@@ -44,6 +44,58 @@ export function contrastRatio(foreground, background) {
return (light + 0.05) / (dark + 0.05);
}
/**
* Makes a number field draggable sideways, the way the Flatsome scrubfield works.
*
* Typing "24" into a field to see whether 24 is right, then "26", then "22", is three round
* trips for one decision. Dragging answers it in one motion. Typing and the arrow keys keep
* working untouched - this is added, nothing is taken away.
*
* Only for mouse and pen: on a touch screen a horizontal drag over a field is how the panel is
* scrolled, and stealing that would make the panel unusable on a phone.
*/
function makeScrubbable(input, { apply, step, mergeKey }) {
const size = 'any' === step || !step ? 1 : Number(step);
input.classList.add('scrubbable');
let startX = 0, startValue = 0, active = false, moved = false;
input.addEventListener('pointerdown', event => {
if (0 !== event.button || input.disabled || 'touch' === event.pointerType) return;
startX = event.clientX;
startValue = '' === input.value ? 0 : Number(input.value);
active = Number.isFinite(startValue);
moved = false;
});
input.addEventListener('pointermove', event => {
if (!active) return;
const distance = event.clientX - startX;
// A few pixels of slack, so a plain click still lands in the field to type in.
if (!moved && Math.abs(distance) < 3) return;
if (!moved) { moved = true; input.setPointerCapture(event.pointerId); }
event.preventDefault();
// Shift is coarse, Alt is fine - the two speeds a value needs while being judged by eye.
const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
const raw = startValue + distance * size * scale;
const rounded = Math.round(raw / (size * scale)) * (size * scale);
const min = '' === input.min ? -Infinity : Number(input.min);
const max = '' === input.max ? Infinity : Number(input.max);
const value = Math.min(max, Math.max(min, Math.round(rounded * 1000) / 1000));
if (String(value) === input.value) return;
input.value = String(value);
apply(String(value), mergeKey);
});
const stop = event => {
if (!active) return;
active = false;
if (!moved) return;
if (input.hasPointerCapture?.(event.pointerId)) input.releasePointerCapture(event.pointerId);
// The drag was one decision, so it closes as one entry in the history.
apply(input.value, null);
};
input.addEventListener('pointerup', stop);
input.addEventListener('pointercancel', stop);
input.title = 'Zum Ändern seitwärts ziehen. Umschalt grob, Alt fein.';
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -51,8 +103,23 @@ function element(tag, className, text) {
return node;
}
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
const change = mutate => { getHistory().change(mutate); onChange(); };
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
/**
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
*
* `keepPanel` is what makes dragging possible at all: a change normally re-renders this panel,
* which replaces the very field the pointer is holding on to. The drag would end after its
* first pixel. While scrubbing, the document and the preview are updated and the panel is left
* standing; it is redrawn once, when the pointer is released.
*/
const change = (mutate, mergeKey, keepPanel = false) => {
getHistory().change(mutate, mergeKey);
onChange({ keepPanel });
};
// Whether the sides of a property move together. This has to outlive the panel: switching the
// chain triggers a change, a change re-renders the panel, and a state kept in the button alone
// would be gone by the time the next keystroke arrives.
const linkedSides = new Map();
// Lives as long as the editor session, so a look can travel between blocks and pages.
let clipboard = null;
@@ -73,13 +140,54 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
}
};
/** Short marks for the chips; the full device name is in the title and the accessible name. */
const SHORT = { desktop: 'D', tablet: 'T', mobile: 'M' };
/** Does this device carry an own value for this property - as a plain value or per side? */
const hasOwn = (block, deviceName, key, hover) => {
const bucket = hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
const value = bucket?.[key];
if (value === undefined) return false;
// A per-side property stores an object; an empty one is a leftover, not a value.
return value && typeof value === 'object' ? Object.values(value).some(single => single !== '' && single !== undefined) : value !== '';
};
/**
* Three chips per option showing which devices carry an own value, and switching to one.
*
* Until now a single switch above the preview decided which device the whole panel edited,
* so "is the heading smaller on the phone" could only be answered by switching and looking.
* The chips answer it where the question comes up. Clicking one switches the preview as well:
* editing a value for a device you are not looking at is editing blind.
*/
const deviceChips = (block, key, labelText, hover) => {
const current = getDevice();
const group = element('div', 'device-chips');
group.setAttribute('role', 'group');
group.setAttribute('aria-label', `Gerät für ${labelText}`);
for (const [name, deviceLabel] of Object.entries(devices)) {
const own = hasOwn(block, name, key, hover);
const chip = element('button', 'device-chip' + (own ? ' device-chip-own' : ''), SHORT[name]);
chip.type = 'button';
chip.dataset.device = name;
chip.setAttribute('aria-pressed', String(name === current));
const state = own ? 'eigener Wert' : 'erbt';
chip.title = `${deviceLabel} – ${state}`;
chip.setAttribute('aria-label', `${labelText} für ${deviceLabel} bearbeiten – ${state}`);
chip.addEventListener('click', () => { if (name !== current) onDevice(name); });
group.append(chip);
}
return group;
};
/** One labelled row with a reset button that appears as soon as this device has an own value. */
const row = (id, labelText, control, { own, inherited, onReset }) => {
const row = (id, labelText, control, { own, inherited, onReset, chips }) => {
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
const head = element('div', 'design-row-head');
const label = element('label', null, labelText);
label.htmlFor = id;
head.append(label);
if (chips) head.append(chips);
if (own) {
const reset = element('button', 'text-button design-reset-one', '⟲');
reset.type = 'button';
@@ -132,9 +240,10 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
unit.setAttribute('aria-label', 'Einheit');
for (const name of UNITS) unit.append(new Option(name, name));
unit.value = parts.unit;
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
number.addEventListener('change', push);
const push = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
number.addEventListener('change', () => push());
unit.addEventListener('change', () => { if (number.value !== '') push(); });
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
wrap.append(number, unit);
return wrap;
};
@@ -155,6 +264,11 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
slider.addEventListener('input', () => { exact.value = slider.value; });
slider.addEventListener('change', () => apply(slider.value));
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
makeScrubbable(exact, {
apply: (value, mergeKey) => { slider.value = value; apply(value, mergeKey); },
step: property.step,
mergeKey: `scrub-${id}`,
});
wrap.append(slider, exact);
return wrap;
};
@@ -179,35 +293,110 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
return select;
};
/** Four inputs for padding, margin, border width and corners. */
/**
* Padding, margin, border width and corners as a box.
*
* Four stacked fields labelled "Oben, Rechts, Unten, Links" made you read to find out which
* edge you were setting. In a box each field sits on the edge it controls, so the position is
* the label. The chain in the middle keeps all four equal, which is what most of them are.
*
* The unit stays a single choice for the whole property, because four unit menus do not fit a
* 240-pixel panel. Sides that already carry different units are shown as "gemischt" and are
* left alone until a unit is actually picked - a stored 2rem is not silently turned into 2px.
*/
const sideControl = (key, block, deviceName, property) => {
const grid = element('div', 'side-control');
const parts = property.corners ? schema.corners : schema.sides;
for (const [side, label] of Object.entries(parts)) {
const cell = element('div', 'side-cell');
const id = `style-${key}-${side}`;
const caption = element('label', null, label);
caption.htmlFor = id;
const corners = Boolean(property.corners);
const parts = corners ? schema.corners : schema.sides;
const names = Object.keys(parts);
const wrap = element('div', 'side-wrap');
const box = element('div', 'side-box' + (corners ? ' side-box-corners' : ''));
// What is stored right now, per side, split into number and unit.
const stored = Object.fromEntries(names.map(side => {
const current = effectiveStyle(block, deviceName, key, side);
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
}, current.value ? String(current.value) : 'erbt');
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
cell.append(caption, control);
grid.append(cell);
}
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
all.type = 'button';
all.addEventListener('click', () => {
const first = grid.querySelector('input[type=number]');
const unit = grid.querySelector('select');
if (first?.value === '') return;
const own = current.source === deviceName ? current.value : '';
return [side, { own, inherited: current.value, parts: splitLength(own) }];
}));
const usedUnits = [...new Set(names.map(side => stored[side].own ? stored[side].parts.unit : null).filter(Boolean))];
const commonUnit = 1 === usedUnits.length ? usedUnits[0] : '';
const unit = document.createElement('select');
unit.id = `style-${key}-unit`;
unit.className = 'side-unit-select';
unit.setAttribute('aria-label', `Einheit für ${property.label}`);
if (usedUnits.length > 1) unit.append(new Option('gemischt', ''));
for (const name of UNITS) unit.append(new Option(name, name));
unit.value = commonUnit || 'px';
const linked = element('button', 'side-link', '⛓');
linked.type = 'button';
// Off to begin with. It replaces the old "Alle Seiten gleich setzen" button, and that was a
// deliberate act: typing into one field must not quietly rewrite the three others.
linked.setAttribute('aria-pressed', String(linkedSides.get(key) ?? false));
linked.title = 'Alle Seiten gemeinsam ändern';
linked.setAttribute('aria-label', `Alle Seiten von ${property.label} gemeinsam ändern`);
/** The unit a side should be written with: its own, else the chosen one. */
const unitFor = side => (stored[side].own ? stored[side].parts.unit : null) ?? (unit.value || 'px');
const write = (side, number, mergeKey) => {
const targets = linkedSides.get(key) ? names : [side];
change(doc => {
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
for (const target of targets) {
setStyle(doc, getSelection(), deviceName, key, '' === number ? '' : `${number}${unitFor(target)}`, { side: target });
}
}, mergeKey, Boolean(mergeKey));
};
for (const side of names) {
const id = `style-${key}-${side}`;
const field = document.createElement('input');
field.type = 'number';
field.step = 'any';
field.id = id;
field.className = 'side-field';
field.value = stored[side].parts.number;
field.placeholder = stored[side].inherited ? String(stored[side].inherited) : 'erbt';
field.setAttribute('aria-label', `${property.label} ${parts[side]}`);
if (stored[side].inherited && !stored[side].own) field.classList.add('is-inherited');
field.addEventListener('change', () => write(side, field.value, null));
makeScrubbable(field, { apply: (value, mergeKey) => write(side, value, mergeKey), step: 1, mergeKey: `scrub-${id}` });
const cell = element('div', `side-cell side-${side}`);
cell.append(field);
box.append(cell);
}
box.append(linked);
linked.addEventListener('click', () => {
const now = !linkedSides.get(key);
linkedSides.set(key, now);
linked.setAttribute('aria-pressed', String(now));
if (!now) return;
// Switching the chain on levels the sides - otherwise it would claim a state the box does
// not show. The value taken is the first one that exists: starting from an empty top field
// would wipe the sides that do have a value.
const set = names.map(side => stored[side].parts.number).find(number => '' !== number);
if (undefined === set) return;
change(doc => {
for (const target of names) setStyle(doc, getSelection(), deviceName, key, `${set}${unit.value || 'px'}`, { side: target });
});
});
const wrap = element('div', 'side-wrap');
wrap.append(grid, all);
unit.addEventListener('change', () => {
if ('' === unit.value) return;
change(doc => {
for (const side of names) {
const number = stored[side].own ? stored[side].parts.number : '';
if ('' !== number) setStyle(doc, getSelection(), deviceName, key, `${number}${unit.value}`, { side });
}
});
});
const unitRow = element('div', 'side-unit');
const unitLabel = element('label', null, 'Einheit');
unitLabel.htmlFor = unit.id;
unitRow.append(unitLabel, unit);
wrap.append(box, unitRow);
return wrap;
};
@@ -307,7 +496,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const id = `style-${key}`;
const current = effectiveStyle(block, deviceName, key);
const own = block.style?.[deviceName]?.[key] !== undefined;
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
const apply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value), mergeKey, Boolean(mergeKey));
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
const inheritedText = current.value !== undefined && !own
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
@@ -315,7 +504,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
let control;
if (property.sides || property.corners) {
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
const sideHead = element('div', 'design-row-head design-side-head');
sideHead.append(element('p', 'design-group-label', property.label), deviceChips(block, key, property.label, false));
body.append(sideHead, sideControl(key, block, deviceName, property));
continue;
}
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
@@ -326,12 +517,12 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
else continue;
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
if (schema.hoverable.includes(key)) {
const hoverId = `${id}-hover`;
const hoverValue = hoverStyle(block, deviceName, key);
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
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)
: property.type === 'number'
@@ -341,6 +532,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
own: hoverValue !== undefined,
inherited: 'Kein eigener Hover-Wert',
onReset: () => hoverApply(''),
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
}));
}
}
+117 -32
View File
@@ -1,7 +1,34 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
/**
* The toolbox, grouped the way the page is actually built. A flat list of thirty-one types said
* nothing about which of them belong together or where they may go; the first group is the
* scaffolding, the rest is what goes inside it.
*/
export const CATEGORIES = [
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
];
/** How many columns a row can be added with. */
export const ROW_COLUMNS = [2, 3, 4];
/**
* Why a type cannot be added where the selection currently is. The toolbox says this instead of
* simply greying a button out: a rule you are told is one you learn, a button that merely stops
* working is one you keep pressing.
*/
export function whyNot(type) {
if (REQUIRE[type]) return `${labels[type]}: gehört in ${REQUIRE[type].map(parent => labels[parent]).join(' oder ')}. Wähle dort zuerst ein Element aus.`;
if (ROOT_NEEDS_CONTAINER.includes(type)) return `${labels[type]}: gehört in eine Sektion. Füge zuerst eine Sektion ein oder wähle eine aus.`;
return `${labels[type]}: passt an dieser Stelle nicht. Wähle ein anderes Element aus.`;
}
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
@@ -9,14 +36,37 @@ export const blockIcon = block => icons[block?.type] ?? '❔';
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
// Containers that accept only one kind of child, mirrored by the server side validator.
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
/** Types DocumentValidator refuses outside their group: an entry without its accordion, a tab
without its tab group. An image is not one of them - it stands on its own anywhere. */
export const NEEDS_GROUP = ['accordionitem', 'tabitem'];
// ---------------------------------------------------------------------------------------------
// What may sit inside what.
//
// Three rules, the same three the Flatsome UX Builder uses, because they cover the cases and
// because they are the ones people already have in their hands:
//
// ALLOW a container that takes only these children and nothing else
// REQUIRE a block that exists only inside one of these parents
// ROOT_NEEDS_CONTAINER
// a block that may sit in any container but not loose at the top of a document
//
// Until now a row took anything - a heading could land directly in it, next to the columns -
// and a column could stand anywhere. That is why the toolbox offered rows and columns out of
// nowhere. The structure the editor has always described in its own hint text is
// section -> row -> column -> content, and these rules are that sentence made binding.
// ---------------------------------------------------------------------------------------------
/** Containers that take one kind of child and nothing else. */
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
/** Blocks that exist only inside one of these parents. */
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
/**
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
* hold one - but it is not a thing a document starts with. At the top level a page begins with
* a section, and that is where a row belongs.
*/
export const ROOT_NEEDS_CONTAINER = ['row'];
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block?.type)
&& (block?.type !== 'accordionitem' || parent.type === 'accordion')
&& (block?.type !== 'tabitem' || parent.type === 'tabs');
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
export const clone = value => structuredClone(value);
export function locate(blocks, id, parent = null) {
@@ -77,16 +127,35 @@ export function upgradeForType(document, type) {
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
}
/**
* Where clicking a type in the toolbox would put it - without putting it anywhere.
*
* Asking and doing used to be the same step, so nothing could tell beforehand whether a type
* fits at all. The toolbox needs exactly that answer to grey out what does not belong here, and
* the answer has to be the one the click will actually follow, or the two drift apart.
*
* The search goes from the inside out: into the selection when it is a container that takes
* this type, otherwise next to it, climbing until some ancestor accepts it, and finally the top
* level. Returns null when the type cannot go anywhere from this selection.
*
* @return {{parentId: string|null, index: number}|null}
*/
export function insertionTarget(document, selected, type) {
const probe = { type };
let item = locate(document.blocks, selected);
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
while (item) {
const parentId = item.parent?.id ?? null;
if (dropList(document, parentId, probe)) return { parentId, index: item.index + 1 };
item = item.parent ? locate(document.blocks, item.parent.id) : null;
}
return dropList(document, null, probe) ? { parentId: null, index: document.blocks.length } : null;
}
export function insertBlock(document, type, selected) {
const block = createBlock(type);
upgradeForType(document, type);
let target = locate(document.blocks, selected);
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
if (!target) document.blocks.push(block);
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
else target.siblings.splice(target.index + 1, 0, block);
return block.id;
const target = insertionTarget(document, selected, type);
if (!target) return null;
return insertBlockAt(document, type, target.parentId, target.index);
}
export function duplicateBlock(document, selected) {
@@ -123,24 +192,29 @@ export function moveBlock(document, selected, targetId, inside = false) {
* and nothing else, an accordion takes entries.
*/
export function dropList(document, parentId, block) {
// The top level mirrors DocumentValidator: everything is allowed there except the two types
// that only ever exist inside their group. Being stricter here than the validator would make
// dragging refuse what the buttons happily insert.
if (parentId === null) return NEEDS_GROUP.includes(block?.type) ? null : document.blocks;
if (parentId === null) {
const type = block?.type;
const loose = type !== undefined && (REQUIRE[type] !== undefined || ROOT_NEEDS_CONTAINER.includes(type));
return loose ? null : document.blocks;
}
const parent = locate(document.blocks, parentId)?.block;
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
}
/** Inserts a new block at an exact position instead of behind the selection. */
export function insertBlockAt(document, type, parentId, index) {
const block = createBlock(type);
upgradeForType(document, type);
/** Puts an already built block at an exact position, or refuses when the parent will not take it. */
function place(document, block, parentId, index) {
const list = dropList(document, parentId, block);
if (!list) return null;
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
return block.id;
}
/** Inserts a new block at an exact position instead of behind the selection. */
export function insertBlockAt(document, type, parentId, index) {
upgradeForType(document, type);
return place(document, createBlock(type), parentId, index);
}
/**
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
* block dragged between two others lands between them and not at the end of the container.
@@ -284,18 +358,29 @@ export function insertHero(document, selected) {
return section.id;
}
export function insertRow(document, columns, selected) {
/** A row with its columns already in it. On its own an empty row is a box with nothing in it. */
function buildRow(columns) {
const count = Math.max(1, Math.min(6, Number(columns) || 2));
const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' };
const row = createBlock('row');
row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } };
row.children = Array.from({ length: count }, () => createBlock('column'));
return row;
}
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
export function insertRowAt(document, columns, parentId, index) {
upgradeDocument(document);
const target = locate(document.blocks, selected);
if (!target) document.blocks.push(row);
else if (canContain(target.block, row)) (target.block.children ??= []).push(row);
else target.siblings.splice(target.index + 1, 0, row);
return row.id;
return place(document, buildRow(columns), parentId, index);
}
export function insertRow(document, columns, selected) {
const row = buildRow(columns);
upgradeDocument(document);
// Same placement rule as a single element, so the row does not land somewhere else than the
// toolbox promised.
const target = insertionTarget(document, selected, 'row');
return target ? place(document, row, target.parentId, target.index) : null;
}
export function insertPreset(document, name, selected) {
+50 -16
View File
@@ -50,10 +50,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.panel-section .eyebrow{margin-bottom:10px}
.panel-section h2{font-size:14px;margin-bottom:12px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}
.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}
.library button span{font-size:19px;line-height:1}
.panel-title{display:flex;justify-content:space-between;align-items:center}
.panel-title h2{margin-bottom:0}
.panel-title+.panel-hint{margin-top:10px}
@@ -150,10 +146,33 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:6px}
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
.side-wrap{margin-bottom:10px}
/* Each field sits on the edge it controls, so the position is the label. */
.side-box{display:grid;grid-template-columns:1fr 1fr 1fr;grid-template-rows:auto auto auto;gap:4px;align-items:center;justify-items:center;padding:7px;border:1px dashed var(--line);border-radius:8px;background:#fbfcf8}
.side-top{grid-area:1/2}.side-left{grid-area:2/1}.side-right{grid-area:2/3}.side-bottom{grid-area:3/2}
.side-box-corners .side-topLeft{grid-area:1/1}.side-box-corners .side-topRight{grid-area:1/3}
.side-box-corners .side-bottomLeft{grid-area:3/1}.side-box-corners .side-bottomRight{grid-area:3/3}
.side-cell{width:100%}
.side-field{width:100%;min-width:0;text-align:center;padding:5px 2px;font-size:11px;min-height:30px}
.side-field.is-inherited{color:var(--muted)}
.side-link{grid-area:2/2;width:26px;height:26px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px;line-height:1;color:var(--muted);cursor:pointer}
.side-link[aria-pressed=true]{border-color:var(--accent);background:#eef5e6;color:var(--accent)}
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
.side-unit-select{flex:1;min-width:0}
/* The device chips on a single option: which devices carry an own value, and switch to one. */
.device-chips{display:flex;gap:2px;margin-left:auto}
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
.device-chip:hover{border-color:var(--accent)}
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
.design-side-head{margin-bottom:5px}
.design-side-head .design-group-label{margin:0}
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
.scrubbable{cursor:ew-resize}
.scrubbable:focus{cursor:text}
.design-actions{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%}
@@ -188,9 +207,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.editor-page-name h1{font-size:14px}
.panel-section h2{font-size:13px;letter-spacing:0}
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
.library{gap:8px}
.library button{min-height:68px;border-radius:9px;font-size:11px;gap:7px}
.library button span{font-size:20px}
.tree-item{font-size:12px;border-radius:6px}
.right-panel label{font-size:12px}
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
@@ -204,10 +220,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.canvas-panel{background:#e7ebe5}
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
.icon-button{border-radius:8px}
.row-library{display:grid;gap:6px;margin-bottom:14px}
.row-library button{display:flex;align-items:center;gap:10px;min-height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
.row-library button:hover:not(:disabled){border-color:var(--accent)}
.row-library span{letter-spacing:-2px;font-size:13px}
#design-search{font-size:12px;min-height:36px;padding:9px}
/* --------------------------------------------------------------------------------------------
@@ -241,8 +253,30 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
#library button[draggable=true]:active{cursor:grabbing}
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
.insert-hint .text-button{margin-left:4px}
#toolbox.awaiting-insert .library button[data-add]{box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
fade on the insert points is a transition and is switched off with the rest. */
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
/* --------------------------------------------------------------------------------------------
The toolbox, grouped the way a page is built.
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
element would land is disabled and carries the reason in its title, so the rule is learned
rather than merely enforced.
-------------------------------------------------------------------------------------------- */
.library-group + .library-group{margin-top:16px}
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.library-buttons button{display:flex;align-items:center;gap:7px;text-align:left;min-height:38px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
.library-buttons button>span:first-child{flex:none;width:14px;text-align:center;font-size:13px;color:var(--accent)}
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
.library-buttons button:disabled>span:first-child{color:var(--muted)}
/* A row is offered by column count, so its glyph shows the count - and needs the room for it,
instead of the fixed single-glyph width the other entries share. */
.library-buttons button>span.library-row-glyph{width:auto;letter-spacing:0;font-size:10px;color:var(--accent)}
.library-buttons button[data-row]{grid-column:span 2}
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
+102 -21
View File
@@ -1,4 +1,4 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { EditorHistory, labels, icons, 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 { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js';
@@ -65,8 +65,7 @@ function controls() {
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
$('#outdent').disabled ||= !current.parent;
}
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
updateLibrary();
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
@@ -659,10 +658,11 @@ async function loadDesignSchema() {
designSchema = await response.json();
designPanel = createDesignPanel({
schema: designSchema,
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
onChange: options => { controls(); renderTree(); schedulePreview(); if (!options?.keepPanel) renderProperties(); },
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
getBlock: () => locate(history.document.blocks, selected)?.block,
getDevice: () => device,
onDevice: setViewport,
getSelection: () => selected,
getHistory: () => history,
});
@@ -718,6 +718,7 @@ function addElement(type) {
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
function setPendingInsert(target) {
pendingInsert = target;
updateLibrary();
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
$('#insert-hint').hidden = !target;
if (target) {
@@ -726,17 +727,84 @@ function setPendingInsert(target) {
}
}
// ---------------------------------------------------------------------------------------------
// The toolbox.
//
// It is built here rather than listed in the template because it has to answer a question that
// only the current selection can answer: what fits where the next element would go. A flat list
// of every type offered a row on an empty page and a column next to nothing - the structure the
// editor describes in its own hint was not the structure the buttons followed.
// ---------------------------------------------------------------------------------------------
function buildLibrary() {
const library = $('#library');
library.replaceChildren();
for (const category of CATEGORIES) {
const group = document.createElement('div'); group.className = 'library-group';
const heading = document.createElement('h4'); heading.className = 'library-group-title'; heading.textContent = category.name;
group.append(heading);
if (category.hint) {
const hint = document.createElement('p'); hint.className = 'library-group-hint'; hint.textContent = category.hint;
group.append(hint);
}
const list = document.createElement('div'); list.className = 'library-buttons';
for (const type of category.types) {
// A row without columns is an empty box, so it is offered by column count instead - the
// shape people actually want when they reach for a row.
for (const columns of 'row' === type ? ROW_COLUMNS : [null]) {
const button = document.createElement('button');
button.type = 'button'; button.draggable = true;
button.dataset.add = type;
if (columns) button.dataset.row = String(columns);
const glyph = document.createElement('span'); glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = columns ? '▥'.repeat(Math.min(columns, 4)) : icons[type];
if (columns) glyph.className = 'library-row-glyph';
const text = document.createElement('span');
text.textContent = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
button.append(glyph, text);
list.append(button);
}
}
group.append(list);
library.append(list.children.length ? group : document.createComment('leer'));
}
}
/**
* Greys out what cannot go where the next element would land, and says where it will land when
* it can. The answer comes from the same function the click follows, so the two cannot disagree.
*/
function updateLibrary() {
const ready = Boolean(history);
for (const button of $('#library').querySelectorAll('button')) {
const type = button.dataset.add;
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
const target = pendingInsert
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
: insertionTarget(history.document, selected, type);
button.disabled = !target;
if (!target) { button.title = 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.`;
}
}
$('#library').addEventListener('click', event => {
const type = event.target.closest('[data-add]')?.dataset.add;
if (type) addElement(type);
const button = event.target.closest('[data-add]');
if (!button || button.disabled) return;
if (button.dataset.row) {
if (busy || !history) return;
const at = pendingInsert;
setPendingInsert(null);
const id = history.change(doc => at
? insertRowAt(doc, button.dataset.row, at.parentId, at.index)
: insertRow(doc, button.dataset.row, selected));
if (id) { selected = id; propertyPanel = 'design'; changed(); }
return;
}
addElement(button.dataset.add);
});
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
// A row with columns, in one click: the piece people actually look for when they want a row.
$('#rows').addEventListener('click', event => {
const columns = event.target.closest('[data-row]')?.dataset.row;
if (!columns || busy || !history) return;
selected = history.change(doc => insertRow(doc, columns, selected)); propertyPanel = 'design'; changed();
});
$('#presets').addEventListener('click', event => {
const name = event.target.closest('[data-preset]')?.dataset.preset;
if (!name || busy || !history) return;
@@ -834,7 +902,9 @@ overlay = createCanvasOverlay({
onDrop: (payload, plan) => {
if (busy || !history) return;
if (payload.kind === 'add') {
const id = history.change(doc => insertBlockAt(doc, payload.type, plan.parentId, plan.index));
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(); }
return;
}
@@ -847,12 +917,13 @@ overlay = createCanvasOverlay({
// toolbox remains usable with the keyboard alone.
$('#library').addEventListener('dragstart', event => {
const button = event.target.closest('[data-add]');
if (!button || busy || !history) { event.preventDefault(); return; }
if (!button || button.disabled || busy || !history) { event.preventDefault(); return; }
const type = button.dataset.add;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', button.dataset.add);
event.dataTransfer.setData('text/plain', type);
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
// one does not exist until it lands.
overlay.startDrag({ kind: 'add', type: button.dataset.add, block: { type: button.dataset.add } });
overlay.startDrag({ kind: 'add', type, columns: button.dataset.row ?? null, block: { type } });
});
$('#library').addEventListener('dragend', () => overlay.endDrag());
@@ -892,11 +963,20 @@ $('#download').addEventListener('click', () => {
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
});
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
$('#preview').className = `viewport-${button.dataset.viewport}`;
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
}));
/**
* Switches the preview size and the device the design panel edits. Both move together on
* purpose: editing the mobile value while the preview shows desktop means changing something
* you cannot see.
*/
function setViewport(name) {
if (!devices[name]) return;
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.viewport === name)));
$('#preview').className = `viewport-${name}`;
device = name;
if (history) history.mergeKey = null;
resizePreview(); renderProperties(); controls();
}
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
window.addEventListener('keydown', event => {
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
@@ -907,4 +987,5 @@ window.addEventListener('keydown', event => {
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
});
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
buildLibrary();
load();
+122
View File
@@ -39,6 +39,14 @@ try {
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
page.on('dialog', dialog => dialog.accept());
// Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als
// `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update
// fuer immer veraltet - genau so brach der Editor mit "does not provide an export named ...".
const skripte = [];
page.on('request', anfrage => {
const url = new URL(anfrage.url());
if (url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js')) skripte.push(url);
});
const axe = async name => {
console.log('Accessibility check:', name);
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical
@@ -61,6 +69,13 @@ try {
await page.locator('a[href="/admin/pages/test-home"]').first().click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
console.log('Editor data loaded.');
// Der Editor laedt fuenf Module ueber Importe, die selbst keine Fassung tragen; die
// Importkarte in base.html.twig haengt sie an. Ohne sie kaeme das alte Modul aus dem Cache.
assert.ok(skripte.some(url => url.pathname === '/assets/editor-model.js'), 'editor-model.js wird geladen');
const ohneFassung = [...new Set(skripte.filter(url => !url.searchParams.has('v')).map(url => url.pathname))];
assert.deepEqual(ohneFassung, [], `Skripte ohne Fassungskennung: ${ohneFassung.join(', ')}`);
checks.push({ name: 'every module is requested with a version key', passed: true });
console.log(`Fassungskennung an allen ${new Set(skripte.map(u => u.pathname)).size} Skriptdateien.`);
console.log('Preview status:', await page.locator('#preview-status').innerText());
const frame = page.frameLocator('#preview');
await frame.locator('h1').waitFor();
@@ -119,6 +134,46 @@ try {
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
// Eine Zeile kommt nicht aus dem Nichts, und eine Spalte gehoert in eine Zeile.
const toolboxState = type => page.evaluate(selector => {
const button = document.querySelector(selector);
return button ? { disabled: button.disabled, title: button.title } : null;
}, `#library button[data-add="${type}"]:not([data-row])`);
const rowState = () => page.evaluate(() => {
const button = document.querySelector('#library button[data-row="2"]');
return { disabled: button.disabled, title: button.title };
});
await page.locator('#select-page').click();
assert.equal((await toolboxState('column')).disabled, true, 'Spalte ohne Zeile gesperrt');
assert.match((await toolboxState('column')).title, /gehört in Zeile/);
assert.equal((await rowState()).disabled, true, 'Zeile ohne Sektion gesperrt');
assert.match((await rowState()).title, /gehört in eine Sektion/);
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// einem anderen Dokument.
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
// Und in der Zeile wird die Spalte moeglich.
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
await page.locator('#library button[data-row="3"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
// Differenz.
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
checks.push({ name: 'the toolbox follows the structure', passed: true });
await page.screenshot({ path: 'reports/editor/toolbox.png' });
console.log('Context aware toolbox passed.');
// Zurueck in den Zustand, den dieser Abschnitt vorgefunden hat: die folgenden Pruefungen
// setzen eine bestehende Auswahl voraus.
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).click();
@@ -233,6 +288,73 @@ try {
await setLength('style-padding-top', 32);
await frameValue('#hero', 'paddingTop', '32px');
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
// --- Die Optionen selbst ------------------------------------------------------------------
// Geraetechips je Option, Abstandsrahmen mit Kette, Ziehfeld.
// Der Chip zeigt, wo ein abweichender Wert liegt: Innenabstand hat oben gerade Desktop und
// Mobil bekommen, Tablet nicht.
const chip = device => page.locator(`#style-padding-unit`).locator('xpath=ancestor::div[contains(@class,"side-wrap")]/preceding-sibling::div[contains(@class,"design-side-head")][1]')
.locator(`.device-chip[data-device="${device}"]`);
await chip('desktop').waitFor();
assert.equal(await chip('desktop').evaluate(el => el.classList.contains('device-chip-own')), true, 'Desktop hat einen eigenen Innenabstand');
assert.equal(await chip('mobile').evaluate(el => el.classList.contains('device-chip-own')), true, 'Mobil hat einen eigenen Innenabstand');
assert.equal(await chip('tablet').evaluate(el => el.classList.contains('device-chip-own')), false, 'Tablet erbt');
assert.equal(await chip('desktop').getAttribute('aria-pressed'), 'true', 'Desktop ist die bearbeitete Groesse');
// Ein Klick auf den Chip wechselt Bedienfeld und Vorschau zusammen.
await chip('mobile').click();
assert.equal(await page.locator('[data-viewport="mobile"]').getAttribute('aria-pressed'), 'true');
await chip('desktop').click();
assert.equal(await page.locator('[data-viewport="desktop"]').getAttribute('aria-pressed'), 'true');
// Der Rahmen aendert ohne Kette nur die eine Seite.
await setLength('style-padding-left', 24);
await frameValue('#hero', 'paddingLeft', '24px');
await frameValue('#hero', 'paddingRight', '0px');
// Mit Kette werden alle vier gleichgezogen.
await page.locator('.side-link').first().click();
assert.equal(await page.locator('.side-link').first().getAttribute('aria-pressed'), 'true');
await setLength('style-padding-top', 40);
for (const seite of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']) await frameValue('#hero', seite, '40px');
await page.locator('.side-link').first().click();
// Die gemeinsame Einheit schreibt die gesetzten Seiten um, ohne Zahlen zu verlieren.
await page.locator('#style-padding-unit').selectOption('rem');
await frameValue('#hero', 'paddingTop', '640px');
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
await page.locator('#structure [data-block-id="headline"]').click();
await openGroup('Typografie');
const scrub = page.locator('#style-fontSize');
await scrub.scrollIntoViewIfNeeded();
const box = await scrub.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2, { steps: 10 });
await page.mouse.up();
const gezogen = Number(await scrub.inputValue());
assert.equal(gezogen, 92, `Ziehen um 20 px erhoeht 72 auf 92, gemessen: ${gezogen}`);
await frameValue('#headline', 'fontSize', '92px');
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await frameValue('#headline', 'fontSize', '72px');
checks.push({ name: 'device chips, spacing box and scrub fields', passed: true });
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
await page.locator('#structure [data-block-id="hero"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#style-padding-unit').selectOption('px');
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
await setLength('style-padding-top', 96);
await frameValue('#hero', 'paddingTop', '96px');
await frameValue('#hero', 'paddingLeft', '0px');
await openGroup('Abstand');
await page.locator('#style-padding-top').scrollIntoViewIfNeeded();
await page.screenshot({ path: 'reports/editor/options.png' });
console.log('Per option device chips, spacing box and scrub fields passed.');
// The background group is collapsed by default, and the readable field next to the swatch is
// what a person types into.
await openGroup('Hintergrund');
+30 -3
View File
@@ -3,6 +3,7 @@
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
@@ -14,12 +15,34 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => ['onResponse', -300]];
return [KernelEvents::REQUEST => ['onRequest', 300], KernelEvents::RESPONSE => ['onResponse', -300]];
}
private function isAdmin(string $path): bool
{
return 1 === preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $path);
}
/**
* One value per administration page, for the single inline script there is: the import map.
*
* It has to be inline - the specification knows no external import maps - and it has to work
* under `script-src 'self'`. Without it the map is dropped without a word, the modules load
* without a version key, and a year-old `editor-model.js` out of the cache meets a new
* `editor.js`. That is not a theory: it broke the editor after 1.5.0 went out.
*/
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$event->isMainRequest() || !$this->isAdmin($request->getPathInfo())) {
return;
}
$request->attributes->set('csp_nonce', rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='));
}
public function onResponse(ResponseEvent $event): void
{
if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) {
if (!$this->isAdmin($event->getRequest()->getPathInfo())) {
return;
}
$headers = $event->getResponse()->headers;
@@ -30,6 +53,10 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
// The preview must show the same typeface as the published page, so the font hosts are
// allowed here as well. Everything else stays as narrow as on the public side.
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
// else, so nothing is loosened by it.
$nonce = $event->getRequest()->attributes->get('csp_nonce');
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Twig;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* The nonce for the one inline script the administration needs.
*
* `script-src 'self'` has no room for an inline script, and the import map has to be one: the
* specification knows no external import maps. AdminHeadersSubscriber puts a fresh value into the
* request for every administration page and names it in the policy; this hands the same value to
* the template. Outside the administration there is none, and the function then returns an empty
* string instead of inventing one that no policy allows.
*/
final class CspExtension extends AbstractExtension
{
public function __construct(private RequestStack $requests)
{
}
public function getFunctions(): array
{
return [new TwigFunction('csp_nonce', $this->nonce(...))];
}
public function nonce(): string
{
$value = $this->requests->getCurrentRequest()?->attributes->get('csp_nonce');
return is_string($value) ? $value : '';
}
}
+14
View File
@@ -7,6 +7,20 @@
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
{% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
{# Die Fassungskennung fuer die Module untereinander.
Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'`
darin nicht - und `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable`
zwischenspeichern. Nach einem Update lud der Browser damit das neue `editor.js`, behielt
aber das alte `editor-model.js` und brach mit "does not provide an export named ..." ab.
Die Importkarte haengt die Fassung an die aufgeloeste Adresse, ohne dass eine einzige
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'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
{% block styles %}{% endblock %}
{% block scripts %}{% endblock %}
+2 -9
View File
@@ -20,15 +20,8 @@
<main id="admin-main" class="editor-layout" tabindex="-1">
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken: dann landet es in der gewählten Sektion beziehungsweise hinter dem gewählten Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
<div id="rows" class="row-library">
{% for count, label in {2: 'Zeile, 2 Spalten', 3: 'Zeile, 3 Spalten', 4: 'Zeile, 4 Spalten'} %}
<button type="button" data-row="{{ count }}" disabled><span aria-hidden="true">{{ {2: '▥▥', 3: '▥▥▥', 4: '▥▥▥▥'}[count] }}</span> {{ label }}</button>
{% endfor %}
</div>
<div id="library" class="library">
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" draggable="true" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',postnav:'⇄',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',legal:'§',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
</div>
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken. Der Werkzeugkasten zeigt an, was an der gewählten Stelle hineinpasst — was dort nicht hingehört, sagt beim Überfahren warum.</p>
<div id="library" class="library"></div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
<div id="presets" class="preset-library"><button data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
+93 -2
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
import { readFileSync, readdirSync } from 'node:fs';
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
import { readFile } from 'node:fs/promises';
import { designCss } from '../scripts/build-design.mjs';
@@ -425,3 +425,94 @@ test('the top level takes everything the validator takes there, and nothing else
// Beim Ziehen aus dem Werkzeugkasten gibt es den Block noch nicht.
assert.doesNotThrow(() => dropList(document, 's1', null));
});
// --- Sektion, Zeile, Spalte, Inhalt ----------------------------------------------------------
// Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, gilt jetzt auch fuer
// die Schaltflaechen: eine Zeile nahm bisher jedes Kind, eine Spalte stand ueberall.
test('a row takes columns and nothing else, a column only lives in a row', () => {
const document = fixture();
const rowId = insertRow(document, 3, 's2');
const row = locate(document.blocks, rowId).block;
assert.equal(row.children.length, 3);
assert.ok(row.children.every(child => child.type === 'column'));
// Eine Ueberschrift gehoert nicht neben die Spalten, sondern in eine davon.
assert.equal(insertBlockAt(document, 'heading', rowId, 0), null);
assert.equal(row.children.length, 3);
assert.ok(insertBlockAt(document, 'heading', row.children[0].id, 0));
// Eine Spalte gehoert nicht in eine Sektion.
assert.equal(insertBlockAt(document, 'column', 's2', 0), null);
assert.ok(insertBlockAt(document, 'column', rowId, 3));
});
test('nothing that needs a container is offered at the top of a document', () => {
const document = fixture();
for (const type of ['row', 'column', 'accordionitem', 'tabitem']) {
assert.equal(insertionTarget(document, null, type), null, type);
assert.equal(insertBlock(document, type, null), null, type);
}
// Eine Sektion dagegen schon - damit faengt eine Seite an.
assert.ok(insertBlock(document, 'section', null));
});
test('the toolbox is told the same place the click will use', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
const rowId = insertRow(document, 2, 's2');
const columnId = locate(document.blocks, rowId).block.children[0].id;
// In einer Zeile ist nur eine Spalte moeglich, und sie landet in der Zeile.
assert.deepEqual(insertionTarget(document, rowId, 'column'), { parentId: rowId, index: 2 });
// Eine Ueberschrift nimmt die Zeile nicht - sie steigt auf und landet hinter der Zeile in der
// Sektion. Sperren waere unnoetig: die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
assert.deepEqual(insertionTarget(document, rowId, 'heading'), { parentId: 's2', index: 1 });
// In einer Spalte ist Inhalt moeglich, und er landet in der Spalte.
assert.equal(insertionTarget(document, columnId, 'heading').parentId, columnId);
// Eine weitere Spalte neben der gewaehlten: eine Ebene hoeher, direkt dahinter.
assert.deepEqual(insertionTarget(document, columnId, 'column'), { parentId: rowId, index: 1 });
// Und die Auskunft stimmt mit der Tat ueberein.
for (const [selection, type] of [[rowId, 'column'], [columnId, 'heading'], [columnId, 'column'], ['s2', 'row']]) {
const promised = insertionTarget(document, selection, type);
const id = insertBlock(document, type, selection);
assert.equal(locate(document.blocks, id).parent?.id ?? null, promised.parentId, `${type} aus ${selection}`);
}
});
test('every block type appears in exactly one toolbox group', () => {
const listed = CATEGORIES.flatMap(category => category.types);
assert.equal(listed.length, new Set(listed).size, 'kein Typ doppelt');
assert.deepEqual([...listed].sort(), Object.keys(blockLabels).sort());
});
test('a row dropped at an exact position brings its columns along', () => {
const document = fixture();
assert.ok(insertRowAt(document, 4, 's1', 0));
const row = locate(document.blocks, 's1').block.children[0];
assert.equal(row.type, 'row');
assert.equal(row.children.length, 4);
// Und in eine Zeile hinein gibt es keine zweite Zeile.
assert.equal(insertRowAt(document, 2, row.id, 0), null);
});
// --- Fassungskennung an den Modulen ---------------------------------------------------------
test('every module imported by another one is listed in the import map', () => {
// `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable` zwischenspeichern.
// Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'` darin
// nicht - der Browser behielte das alte Modul und der Editor braeche mit "does not provide an
// export named ..." ab. Genau das ist nach 1.5.0 passiert. Die Importkarte haengt die Fassung
// an; ein neues Modul, das dort fehlt, faellt hier auf und nicht erst beim naechsten Update.
const quellen = readdirSync('public/assets').filter(name => name.endsWith('.js'));
const importiert = new Set();
for (const name of quellen) {
for (const treffer of readFileSync(`public/assets/${name}`, 'utf8').matchAll(/from '\.\/([\w-]+\.js)'/g)) {
importiert.add(treffer[1]);
}
}
assert.ok(importiert.size > 0, 'es gibt ueberhaupt Modulimporte');
const vorlage = readFileSync('templates/admin/base.html.twig', 'utf8');
const karte = /<script type="importmap"[\s\S]*?<\/script>/.exec(vorlage);
assert.ok(karte, 'base.html.twig traegt eine Importkarte');
const eingetragen = new Set([...karte[0].matchAll(/'([\w-]+)'/g)].map(treffer => `${treffer[1]}.js`));
const fehlend = [...importiert].filter(name => !eingetragen.has(name)).sort();
assert.deepEqual(fehlend, [], `ohne Fassungskennung in der Importkarte: ${fehlend.join(', ')}`);
});