Compare commits

..
4 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 d1ac9d760b Fassung 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
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
In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
gesamte linke Navigation - Marke oben, Fusszeile unten, dazwischen nichts.

Die Navigation steckt in einem <details>: auf dem Telefon ein Klappmenue, am
Schreibtisch die stehende Spalte. Am Schreibtisch wurde der Schalter mit
display:none ausgeblendet - aber das Element trug kein open, und ein
geschlossenes details versteckt seinen Inhalt selbst. Schalter weg und Inhalt
weg.

Behoben mit ::details-content, das den Inhalt am Schreibtisch zurueckholt,
waehrend der Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht,
faellt ueber @supports die ganze Gruppe weg: dann steht der Schalter da und die
Liste ist einen Klick entfernt statt gar nicht da.

Der Code dafuer lag bereits im Arbeitsverzeichnis - eine parallel laufende
Sitzung hatte ihn geschrieben, aber nicht committet, und damit war er in keinem
Release. Uebernommen ist hier nur diese Korrektur, nicht die uebrige Arbeit
jener Sitzung.

Wie das durchrutschen konnte: beim vorigen Bericht wurde die Navigation fuer in
Ordnung erklaert, nachdem eine Nachbildung der Regel in einer leeren Seite
geprueft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte
Datei nicht - der Befund war wertlos. Jetzt prueft der Browsertest die echte
Verwaltungsseite, auf beiden Groessen. Gegenprobe gemacht: gegen die
ausgelieferte admin.css schlaegt er fehl.

Geprueft: npm run test:editor vollstaendig, npm test 28/28, phpunit 452/452.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:17:56 +02:00
kantarciandClaude Opus 5 7c3c8671a7 Fassung 1.5.4: der Werkzeugkasten bekommt Bilder und eine Suche
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
Die Elementwahl war eine Liste aus Schriftzeichen - "▣" fuer Sektion, "☰" fuer
Zeile. Jetzt traegt jeder der 34 Eintraege eine Strichzeichnung, die zeigt, was
er baut: die Sektion ein gefuelltes Band, "Zeile, 3 Spalten" tatsaechlich drei
Kaesten, das Bild einen Rahmen mit Bergzug.

Zeichnungen statt Bilddateien. Flatsome liefert je Element ein PNG mit.
Gezeichnet ist hier besser: library-previews.js ist ein paar hundert Byte statt
eines Ordners voller Bitmaps, die Formen nehmen die Farbe der Flaeche an, auf
der sie liegen - gesperrte Eintraege werden damit von selbst blass - und sie
bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet: zwei,
drei oder vier Kaesten aus derselben Funktion.

Suche. Ein Feld ueber der Liste filtert ueber alle Gruppen, gesucht wird ueber
Namen, internen Typ und Gruppennamen - wer "slider" eingibt, muss nicht wissen,
dass er unter "Gruppen" steht. Gruppen ohne Treffer verschwinden, darunter
steht wie viele von wie vielen uebrig sind.

Dabei bemerkt: es gab zwei Suchfelder namens "Element suchen" auf einem
Bildschirm, fuer eine Sprachausgabe nicht unterscheidbar. Sie heissen jetzt
nach dem, was sie durchsuchen.

Geprueft: der Browsertest zaehlt die Vorschaubilder gegen die Eintraege und
sucht nach Name, Gruppenname und etwas Nichtvorhandenem. npm test 28/28,
phpunit 452/452, npm run test:editor vollstaendig ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:08:31 +02:00
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
13 changed files with 795 additions and 76 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.5.1 1.5.5
+157 -4
View File
@@ -1,6 +1,6 @@
# Projektstatus # Projektstatus
Stand: 14.09.2026, Fassung **1.5.1**. 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.5**. 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. Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
@@ -321,11 +321,164 @@ werden nicht nachträglich geprüft: eine Überschrift, die früher direkt in ei
ist, wird weiterhin ausgegeben und lässt sich auswählen und löschen, nur nicht mehr dorthin 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. 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.
## Arbeitspaket H, Teil 3b: der Werkzeugkasten bekommt Bilder und eine Suche
**Erledigt.** Die Elementwahl war eine Liste aus Schriftzeichen – „▣“ für Sektion, „☰“ für Zeile.
Jetzt trägt jeder der 34 Einträge eine Strichzeichnung, die zeigt, was er baut: die Sektion ein
gefülltes Band, „Zeile, 3 Spalten“ tatsächlich drei Kästen, das Bild einen Rahmen mit Bergzug.
**Zeichnungen statt Bilddateien.** Flatsome liefert je Element ein PNG mit. Gezeichnet ist hier
besser: `library-previews.js` ist ein paar hundert Byte statt eines Ordners voller Bitmaps, die
Formen nehmen die Farbe der Fläche an, auf der sie liegen – gesperrte Einträge werden damit von
selbst blass – und sie bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet,
nicht gezeichnet: zwei, drei oder vier Kästen aus derselben Funktion.
**Suche.** Ein Feld über der Liste filtert über alle Gruppen. Gesucht wird über den Namen, den
internen Typ **und** den Gruppennamen – wer „slider“ eingibt, muss nicht wissen, dass er unter
„Gruppen“ einsortiert ist. Gruppen ohne Treffer verschwinden, damit die Liste keine Lücken
behält, und darunter steht, wie viele von wie vielen übrig sind.
**Dabei bemerkt:** Es gab damit zwei Suchfelder mit dem Namen „Element suchen“ auf einem
Bildschirm – für eine Sprachausgabe nicht unterscheidbar. Sie heißen jetzt nach dem, was sie
durchsuchen: „Element zum Einfügen suchen“ im Werkzeugkasten, „Element auf der Seite suchen“ im
Strukturbaum.
**Geprüft.** Der Browsertest zählt die Vorschaubilder gegen die Einträge (alle 34 haben eines),
sucht nach „galerie“ (ein Eintrag, eine Gruppe), nach „aufbau“ (fünf Einträge über den
Gruppennamen), nach etwas Nichtvorhandenem und wieder nach nichts. `npm test` 28 von 28,
`php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig ohne axe-Verstoß.
**Noch offen aus Teil 3.** Vorlagen je Element mit Vorschaubild. Und das Ziehen der
Spaltenbreite: das geht heute nicht ohne einen Zwischenschritt, denn eine Zeile ist ein
CSS-Raster mit **gleich breiten** Spalten (`repeat(n, minmax(0,1fr))`); ein `span` je Spalte
gibt es nicht. Dafür braucht es erst ein Zwölf-Spalten-Modell im Designtoken-Manifest, und das
berührt die öffentliche CSS und damit die Messwerte aus AGENTS.md. Das ist ein eigener Schritt,
kein Nebenbei.
## 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
**Der Fehler.** In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
gesamte linke Navigation – Marke oben, Fußzeile unten, dazwischen nichts. Kein Weg zu Seiten,
Mediathek, Einstellungen außer über die Adresszeile.
Die Navigation steckt in einem `<details>`: auf dem Telefon ein Klappmenü, am Schreibtisch die
stehende Spalte. Am Schreibtisch wurde der Schalter mit `.shell-menu > summary { display: none }`
ausgeblendet – aber das Element trug kein `open`, und ein geschlossenes `details` versteckt seinen
Inhalt selbst. Schalter weg **und** Inhalt weg.
**Die Behebung.** `::details-content` holt den Inhalt am Schreibtisch zurück, während der
Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht, fällt über `@supports` die ganze
Gruppe weg: dann steht der Schalter da und die Liste ist einen Klick entfernt – statt gar nicht
da. Auf dem Telefon bleibt es beim Klappmenü.
Der Code dafür lag bereits im Arbeitsverzeichnis: eine parallel laufende Sitzung hatte ihn
geschrieben, aber nicht committet, und damit war er in keinem Release. Übernommen ist hier nur
diese Korrektur, nicht die übrige Arbeit jener Sitzung.
**Wie das durchrutschen konnte – und was dagegen getan ist.** Beim vorigen Bericht wurde die
Navigation für in Ordnung erklärt, nachdem eine **Nachbildung** der Regel in einer leeren Seite
geprüft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte Datei nicht; der
Befund war damit wertlos. Jetzt prüft der Browsertest die **echte** Verwaltungsseite: am
Schreibtisch kein Schalter, Navigation sichtbar, der Eintrag „Seiten“ mit messbarer Fläche; auf
390 px Breite ein Schalter, Liste zunächst zu, ein Klick öffnet sie. Gegenprobe gemacht – gegen
die ausgelieferte `admin.css` schlägt der Test fehl.
**Geprüft.** `npm run test:editor` vollständig, `npm test` 28 von 28,
`php vendor/bin/phpunit` 452 von 452.
## Nächster konkreter Schritt ## Nächster konkreter Schritt
Arbeitspaket H, Teil 3: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
Vorschaubild, Geräteschalter an der einzelnen Option, Ziehfelder für Zahlen, Abstandsrahmen und Vorschaubild und das Ziehen der Spaltenbreite in der Vorschau. Danach
das Ziehen der Spaltenbreite in der Vorschau. Danach
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
Begründung in `docs/FLATSOME-ABGLEICH.md`. Begründung in `docs/FLATSOME-ABGLEICH.md`.
+17 -5
View File
@@ -665,10 +665,8 @@ details.card > summary:hover { background: #fafbf8 }
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */ Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
.shell-menu > summary { display: none } /* Der Schalter traegt seine Gestalt hier, nicht erst im Telefonabschnitt: am Schreibtisch
@media (max-width: 760px) { wird er ausgeblendet, aber nur dort, wo der Inhalt sicher sichtbar ist. */
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
.shell-menu { flex: 1 1 100%; order: 3 }
.shell-menu > summary { .shell-menu > summary {
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer; display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
min-height: 44px; padding: 0 14px; border-radius: 10px; min-height: 44px; padding: 0 14px; border-radius: 10px;
@@ -676,10 +674,24 @@ details.card > summary:hover { background: #fafbf8 }
} }
.shell-menu > summary::-webkit-details-marker { display: none } .shell-menu > summary::-webkit-details-marker { display: none }
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px } .shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
.shell-menu-icon { font-size: 17px }
.shell-menu[open] > summary { margin-bottom: 8px } .shell-menu[open] > summary { margin-bottom: 8px }
.shell-menu-icon { font-size: 17px }
/* Am Schreibtisch steht die Liste dauerhaft da. Ein geschlossenes details-Element versteckt
seinen Inhalt aber von sich aus - der Schalter allein auszublenden loescht die Navigation.
Erst ::details-content holt sie zurueck. Kennt ein Browser die Regel nicht, faellt die
ganze Gruppe weg und der Schalter bleibt stehen: die Liste ist dann einen Klick entfernt
statt gar nicht da. */
@supports selector(::details-content) {
.shell-menu > summary { display: none }
.shell-menu::details-content { content-visibility: visible; block-size: auto }
}
@media (max-width: 760px) {
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
.shell-menu { flex: 1 1 100%; order: 3 }
.shell-menu > summary { display: flex }
.shell-menu nav { display: block } .shell-menu nav { display: block }
.shell-menu:not([open]) nav { display: none } .shell-menu:not([open]) nav { display: none }
.shell-menu:not([open])::details-content { content-visibility: hidden; block-size: 0 }
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 } .nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
.nav-list { display: grid; gap: 2px } .nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */ /* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
+228 -36
View File
@@ -44,6 +44,58 @@ export function contrastRatio(foreground, background) {
return (light + 0.05) / (dark + 0.05); 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) { function element(tag, className, text) {
const node = document.createElement(tag); const node = document.createElement(tag);
if (className) node.className = className; if (className) node.className = className;
@@ -51,8 +103,23 @@ function element(tag, className, text) {
return node; return node;
} }
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) { export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
const change = mutate => { getHistory().change(mutate); onChange(); }; /**
* 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. // Lives as long as the editor session, so a look can travel between blocks and pages.
let clipboard = null; 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. */ /** 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 wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
const head = element('div', 'design-row-head'); const head = element('div', 'design-row-head');
const label = element('label', null, labelText); const label = element('label', null, labelText);
label.htmlFor = id; label.htmlFor = id;
head.append(label); head.append(label);
if (chips) head.append(chips);
if (own) { if (own) {
const reset = element('button', 'text-button design-reset-one', '⟲'); const reset = element('button', 'text-button design-reset-one', '⟲');
reset.type = 'button'; reset.type = 'button';
@@ -132,9 +240,10 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
unit.setAttribute('aria-label', 'Einheit'); unit.setAttribute('aria-label', 'Einheit');
for (const name of UNITS) unit.append(new Option(name, name)); for (const name of UNITS) unit.append(new Option(name, name));
unit.value = parts.unit; unit.value = parts.unit;
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`); const push = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
number.addEventListener('change', push); number.addEventListener('change', () => push());
unit.addEventListener('change', () => { if (number.value !== '') push(); }); unit.addEventListener('change', () => { if (number.value !== '') push(); });
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
wrap.append(number, unit); wrap.append(number, unit);
return wrap; return wrap;
}; };
@@ -155,6 +264,11 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
slider.addEventListener('input', () => { exact.value = slider.value; }); slider.addEventListener('input', () => { exact.value = slider.value; });
slider.addEventListener('change', () => apply(slider.value)); slider.addEventListener('change', () => apply(slider.value));
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.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); wrap.append(slider, exact);
return wrap; return wrap;
}; };
@@ -179,35 +293,110 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
return select; 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 sideControl = (key, block, deviceName, property) => {
const grid = element('div', 'side-control'); const corners = Boolean(property.corners);
const parts = property.corners ? schema.corners : schema.sides; const parts = corners ? schema.corners : schema.sides;
for (const [side, label] of Object.entries(parts)) { const names = Object.keys(parts);
const cell = element('div', 'side-cell');
const id = `style-${key}-${side}`;
const caption = element('label', null, label);
caption.htmlFor = id;
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;
change(doc => {
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
});
});
const wrap = element('div', 'side-wrap'); const wrap = element('div', 'side-wrap');
wrap.append(grid, all); 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 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 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 });
});
});
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; return wrap;
}; };
@@ -307,7 +496,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const id = `style-${key}`; const id = `style-${key}`;
const current = effectiveStyle(block, deviceName, key); const current = effectiveStyle(block, deviceName, key);
const own = block.style?.[deviceName]?.[key] !== undefined; 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 onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
const inheritedText = current.value !== undefined && !own const inheritedText = current.value !== undefined && !own
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}` ? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
@@ -315,7 +504,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
let control; let control;
if (property.sides || property.corners) { 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; continue;
} }
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply); 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 if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
else continue; 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)) { if (schema.hoverable.includes(key)) {
const hoverId = `${id}-hover`; const hoverId = `${id}-hover`;
const hoverValue = hoverStyle(block, deviceName, key); 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' const hoverControl = property.type === 'color'
? colorControl(hoverId, hoverValue ?? '', hoverApply) ? colorControl(hoverId, hoverValue ?? '', hoverApply)
: property.type === 'number' : property.type === 'number'
@@ -341,6 +532,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
own: hoverValue !== undefined, own: hoverValue !== undefined,
inherited: 'Kein eigener Hover-Wert', inherited: 'Kein eigener Hover-Wert',
onReset: () => hoverApply(''), onReset: () => hoverApply(''),
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
})); }));
} }
} }
+34 -10
View File
@@ -146,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{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=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px} .number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:6px} .side-wrap{margin-bottom:10px}
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px} /* Each field sits on the edge it controls, so the position is the label. */
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)} .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-all{font-size:10px;margin-top:8px;padding:4px 6px} .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{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%} .design-actions button{width:100%}
@@ -247,13 +270,14 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.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-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-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{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{display:flex;align-items:center;gap:8px;text-align:left;min-height:44px;padding:6px 8px;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-preview{flex:none;display:grid;place-items:center;width:34px;height:23px;color:var(--accent);opacity:.85}
.library-buttons button:disabled .library-preview{color:var(--muted);opacity:.6}
.library-name{min-width:0}
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff} .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{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} .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}} @media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
#library-found{margin:0 0 10px;min-height:0}
+48 -13
View File
@@ -1,8 +1,9 @@
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 { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
import { openMedia } from './media.js'; import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.js'; import { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js'; import { createRichText } from './rich-text.js';
import { createCanvasOverlay } from './canvas-overlay.js'; import { createCanvasOverlay } from './canvas-overlay.js';
import { preview, rowPreview } from './library-previews.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list. // Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check']; const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
@@ -68,7 +69,7 @@ function controls() {
updateLibrary(); updateLibrary();
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy); $('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = 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); document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
$('[data-panel="design"]').disabled ||= !current; $('[data-panel="design"]').disabled ||= !current;
$('#select-page').disabled = !ready || busy; $('#select-page').disabled = !ready || busy;
$('#open-media-library').disabled = !ready || busy; $('#open-media-library').disabled = !ready || busy;
@@ -658,10 +659,11 @@ async function loadDesignSchema() {
designSchema = await response.json(); designSchema = await response.json();
designPanel = createDesignPanel({ designPanel = createDesignPanel({
schema: designSchema, 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(); }, onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
getBlock: () => locate(history.document.blocks, selected)?.block, getBlock: () => locate(history.document.blocks, selected)?.block,
getDevice: () => device, getDevice: () => device,
onDevice: setViewport,
getSelection: () => selected, getSelection: () => selected,
getHistory: () => history, getHistory: () => history,
}); });
@@ -755,12 +757,18 @@ function buildLibrary() {
button.type = 'button'; button.draggable = true; button.type = 'button'; button.draggable = true;
button.dataset.add = type; button.dataset.add = type;
if (columns) button.dataset.row = String(columns); if (columns) button.dataset.row = String(columns);
const glyph = document.createElement('span'); glyph.setAttribute('aria-hidden', 'true'); const name = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
glyph.textContent = columns ? '▥'.repeat(Math.min(columns, 4)) : icons[type]; // What is searched for: the name, the internal type and the group it sits in. Someone
if (columns) glyph.className = 'library-row-glyph'; // looking for "slider" should not have to know it is filed under "Gruppen".
button.dataset.search = `${name} ${type} ${category.name}`.toLocaleLowerCase('de');
const picture = document.createElement('span');
picture.className = 'library-preview';
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
picture.innerHTML = columns ? rowPreview(columns) : preview(type);
const text = document.createElement('span'); const text = document.createElement('span');
text.textContent = columns ? `${labels[type]}, ${columns} Spalten` : labels[type]; text.className = 'library-name';
button.append(glyph, text); text.textContent = name;
button.append(picture, text);
list.append(button); list.append(button);
} }
} }
@@ -788,6 +796,24 @@ function updateLibrary() {
} }
} }
/** Filters the toolbox. A group without a hit disappears, so the list does not keep its gaps. */
function filterLibrary() {
const term = $('#library-search').value.trim().toLocaleLowerCase('de');
let hits = 0;
for (const group of $('#library').querySelectorAll('.library-group')) {
let visible = 0;
for (const button of group.querySelectorAll('button')) {
const hit = '' === term || (button.dataset.search ?? '').includes(term);
button.hidden = !hit;
if (hit) visible++;
}
group.hidden = 0 === visible;
hits += visible;
}
$('#library-found').textContent = '' === term ? '' : (0 === hits ? 'Kein Element mit diesem Namen.' : `${hits} von ${$('#library').querySelectorAll('button').length}`);
}
$('#library-search').addEventListener('input', filterLibrary);
$('#library').addEventListener('click', event => { $('#library').addEventListener('click', event => {
const button = event.target.closest('[data-add]'); const button = event.target.closest('[data-add]');
if (!button || button.disabled) return; if (!button || button.disabled) return;
@@ -962,11 +988,20 @@ $('#download').addEventListener('click', () => {
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' })); 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); 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))); * Switches the preview size and the device the design panel edits. Both move together on
$('#preview').className = `viewport-${button.dataset.viewport}`; * purpose: editing the mobile value while the preview shows desktop means changing something
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls(); * 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')); new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
window.addEventListener('keydown', event => { window.addEventListener('keydown', event => {
if (!history || busy || !(event.ctrlKey || event.metaKey)) return; if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
+76
View File
@@ -0,0 +1,76 @@
/**
* Line art for the toolbox.
*
* Flatsome ships a PNG per element. Drawings are better here: they are a few hundred bytes
* instead of a folder of bitmaps, they take the colour of the surface they sit on, and they stay
* sharp on a screen of any density. Every shape lives in the same 24x16 field so the entries
* line up in the grid.
*
* Each value is the inside of an `<svg>`; `stroke` and `fill` come from the wrapper, so a shape
* only says what it is, not what colour it has.
*/
/** A filled bar, the building block most of these are made of. */
const bar = (x, y, width, height = 2) => `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${Math.min(height, 2) / 2}" fill="currentColor" stroke="none"/>`;
/** Several bars under each other, as a paragraph of text. */
const lines = (...widths) => widths.map((width, index) => bar(3, 4 + index * 3.4, width, 1.7)).join('');
export const PREVIEWS = {
// Aufbau
section: '<rect x="1" y="3" width="22" height="10" rx="1.5" fill="currentColor" opacity=".14" stroke="none"/><rect x="1" y="3" width="22" height="10" rx="1.5"/>',
row: '<rect x="1" y="3" width="22" height="10" rx="1.5"/><path d="M12 3.6v8.8"/>',
column: '<rect x="8" y="1.5" width="8" height="13" rx="1.5"/>',
// Inhalt
heading: `${bar(3, 3.6, 18, 3)}${bar(3, 9, 11, 2)}`,
text: lines(18, 16, 11),
image: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><circle cx="7.5" cy="6.5" r="1.4" fill="currentColor" stroke="none"/><path d="M3.2 12l5-4.4 3.6 3.1 3-2.6 5 3.9"/>',
button: '<rect x="4" y="4.5" width="16" height="7" rx="2"/>',
icon: '<path d="M12 2.6l2.7 5.6 6 .8-4.4 4.1 1.1 5.8" transform="scale(.86) translate(2 .4)"/><path d="M12 2.6l-2.7 5.6-6 .8 4.4 4.1-1.1 5.8" transform="scale(.86) translate(2 .4)"/>',
divider: '<path d="M2 8h20"/>',
spacer: '<path d="M3 3h18M3 13h18"/><path d="M12 5.6v4.8M10.4 6.8L12 5.2l1.6 1.6M10.4 9.2L12 10.8l1.6-1.6"/>',
// Bausteine
iconcard: `<rect x="3" y="2.5" width="4.5" height="4.5" rx="1.2"/>${bar(3, 9, 12, 1.9)}${bar(3, 12, 8, 1.5)}`,
stat: `${bar(3, 3, 9, 5)}${bar(3, 10.5, 14, 1.7)}`,
quote: `<path d="M3.4 7.6c0-2 1.3-3.2 2.9-3.4M8.4 7.6c0-2 1.3-3.2 2.9-3.4"/><path d="M3.4 7.6h2.9v3.2H3.4zM8.4 7.6h2.9v3.2H8.4z"/>${bar(14, 5.6, 7, 1.6)}${bar(14, 9, 5, 1.6)}`,
cta: `${bar(2.5, 4, 12, 2.4)}<rect x="2.5" y="8.6" width="9" height="5" rx="1.6"/><rect x="14.5" y="8.6" width="7" height="5" rx="1.6" fill="currentColor" stroke="none"/>`,
contact: '<rect x="2.5" y="3.5" width="19" height="9" rx="1.5"/><path d="M2.9 4.4l9.1 5 9.1-5"/>',
logos: [2.5, 8, 13.5, 19].map(x => `<rect x="${x}" y="5.5" width="4.5" height="5" rx="1.2"/>`).join(''),
progress: '<rect x="2" y="6" width="20" height="4" rx="2"/><rect x="2" y="6" width="12" height="4" rx="2" fill="currentColor" stroke="none"/>',
// Gruppen
gallery: [2.5, 12.5].flatMap(x => [2.5, 8.5].map(y => `<rect x="${x}" y="${y}" width="9" height="5" rx="1.2"/>`)).join(''),
accordion: '<rect x="2.5" y="2" width="19" height="4" rx="1.2"/><path d="M18 3.6l1.3 1.3 1.3-1.3"/><rect x="2.5" y="7.5" width="19" height="3" rx="1.2"/><rect x="2.5" y="12" width="19" height="3" rx="1.2"/>',
accordionitem: '<rect x="2.5" y="5.5" width="19" height="5" rx="1.2"/><path d="M17.6 7.4l1.3 1.3 1.3-1.3"/>',
tabs: '<rect x="2.5" y="2" width="6" height="3.5" rx="1" fill="currentColor" stroke="none"/><rect x="9.5" y="2" width="6" height="3.5" rx="1"/><rect x="2.5" y="6.5" width="19" height="7.5" rx="1.2"/>',
tabitem: '<rect x="7" y="2.5" width="10" height="4" rx="1" fill="currentColor" stroke="none"/><rect x="2.5" y="7.5" width="19" height="6" rx="1.2"/>',
slider: '<rect x="5.5" y="3" width="13" height="10" rx="1.5"/><path d="M3.4 6.6L1.6 8l1.8 1.4M20.6 6.6L22.4 8l-1.8 1.4"/>',
// Seite und Daten
postlist: '<rect x="2.5" y="2.5" width="7" height="5" rx="1.2"/><rect x="2.5" y="8.5" width="7" height="5" rx="1.2"/>'
+ `${bar(11.5, 3.2, 10, 1.6)}${bar(11.5, 6, 7, 1.4)}${bar(11.5, 9.2, 10, 1.6)}${bar(11.5, 12, 7, 1.4)}`,
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
};
/** The picture for one element type, ready to drop into the markup. */
export function preview(type) {
const shape = PREVIEWS[type];
if (!shape) return '';
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${shape}</svg>`;
}
/** A row of columns, for the entries that add a row with a column count. */
export function rowPreview(columns) {
const gap = 1.4;
const inner = (22 - gap * (columns - 1)) / columns;
const cells = Array.from({ length: columns }, (_, index) =>
`<rect x="${(1 + index * (inner + gap)).toFixed(2)}" y="3" width="${inner.toFixed(2)}" height="10" rx="1.2"/>`).join('');
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" aria-hidden="true" focusable="false">${cells}</svg>`;
}
+126 -2
View File
@@ -39,6 +39,14 @@ try {
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); }); 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('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
page.on('dialog', dialog => dialog.accept()); 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 => { const axe = async name => {
console.log('Accessibility check:', name); console.log('Accessibility check:', name);
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical // The no-script preview sandbox cannot host axe's frame worker. Audit the identical
@@ -56,11 +64,42 @@ try {
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin'); await page.waitForURL(origin + '/admin');
console.log('Authenticated in isolated test installation.'); console.log('Authenticated in isolated test installation.');
// --- Die Verwaltungsnavigation ------------------------------------------------------------
// Sie steckt in einem details-Element: auf dem Telefon ein Klappmenue, am Schreibtisch die
// stehende Spalte. Am Schreibtisch ist der Schalter ausgeblendet - und ein geschlossenes
// details versteckt seinen Inhalt von sich aus. Ohne eine Regel, die ihn zurueckholt, ist die
// Navigation dort nicht bloss zugeklappt, sondern unerreichbar. Genau so ist 1.5.4 ausgeliefert
// worden, und genau das faellt hier auf.
const navigation = page.getByRole('navigation', { name: 'Verwaltung' });
const schalter = page.locator('.shell-menu > summary');
assert.equal(await schalter.isVisible(), false, 'am Schreibtisch gibt es keinen Menueschalter');
assert.equal(await navigation.isVisible(), true, 'am Schreibtisch steht die Navigation da');
const eintrag = navigation.getByRole('link', { name: 'Seiten', exact: true });
const flaeche = await eintrag.boundingBox();
assert.ok(flaeche && flaeche.height > 0 && flaeche.width > 0, 'ihre Eintraege sind anklickbar');
// Auf dem Telefon dagegen: ein Schalter, zunaechst zu, ein Klick oeffnet.
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await schalter.isVisible(), true, 'auf dem Telefon gibt es den Schalter');
assert.equal(await navigation.isVisible(), false, 'und die Liste ist zunaechst zu');
await schalter.click();
assert.equal(await navigation.isVisible(), true, 'ein Klick oeffnet sie');
await page.setViewportSize({ width: 1600, height: 1100 });
checks.push({ name: 'the administration navigation is reachable on both sizes', passed: true });
console.log('Administration navigation passed.');
await axe('page list'); await axe('page list');
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true }); await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
await page.locator('a[href="/admin/pages/test-home"]').first().click(); await page.locator('a[href="/admin/pages/test-home"]').first().click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
console.log('Editor data loaded.'); 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()); console.log('Preview status:', await page.locator('#preview-status').innerText());
const frame = page.frameLocator('#preview'); const frame = page.frameLocator('#preview');
await frame.locator('h1').waitFor(); await frame.locator('h1').waitFor();
@@ -155,6 +194,24 @@ try {
// Differenz. // Differenz.
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3); 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(); await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
const mitBild = await page.locator('#library button .library-preview svg').count();
const knoepfe = await page.locator('#library button').count();
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
// Die Suche greift ueber alle Gruppen und blendet leere Gruppen aus.
await page.locator('#library-search').fill('galerie');
await page.locator('#library-found').filter({ hasText: 'von' }).waitFor();
assert.equal(await page.locator('#library button:visible').count(), 1, 'genau die Galerie bleibt');
assert.equal(await page.locator('#library .library-group:visible').count(), 1, 'nur ihre Gruppe bleibt');
// Auch der Gruppenname findet: "Aufbau" gehoert zu Sektion, Zeile und Spalte.
await page.locator('#library-search').fill('aufbau');
assert.equal(await page.locator('#library button:visible').count(), 5, 'Sektion, drei Zeilen, Spalte');
await page.locator('#library-search').fill('gibtesnicht');
await page.locator('#library-found').filter({ hasText: 'Kein Element' }).waitFor();
await page.locator('#library-search').fill('');
assert.equal(await page.locator('#library button:visible').count(), knoepfe, 'leere Suche zeigt wieder alles');
checks.push({ name: 'the toolbox follows the structure', passed: true }); checks.push({ name: 'the toolbox follows the structure', passed: true });
await page.screenshot({ path: 'reports/editor/toolbox.png' }); await page.screenshot({ path: 'reports/editor/toolbox.png' });
console.log('Context aware toolbox passed.'); console.log('Context aware toolbox passed.');
@@ -204,11 +261,11 @@ try {
await page.getByRole('button', { name: 'Einklappen', exact: true }).click(); await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
assert.equal(await page.locator('#structure .tree-item').count(), 3); assert.equal(await page.locator('#structure .tree-item').count(), 3);
await page.getByLabel('Element suchen', { exact: true }).fill('Meine bearbeitete'); await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
assert.equal(await page.locator('#structure .tree-item').count(), 2); assert.equal(await page.locator('#structure .tree-item').count(), 2);
await page.locator('#structure [data-block-id="headline"]').focus(); await page.locator('#structure [data-block-id="headline"]').focus();
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.getByLabel('Element suchen', { exact: true }).fill(''); await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true); assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click(); await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
await page.getByRole('button', { name: 'Design', exact: true }).click(); await page.getByRole('button', { name: 'Design', exact: true }).click();
@@ -273,6 +330,73 @@ try {
await setLength('style-padding-top', 32); await setLength('style-padding-top', 32);
await frameValue('#hero', 'paddingTop', '32px'); await frameValue('#hero', 'paddingTop', '32px');
await page.getByRole('button', { name: 'Desktop', exact: true }).click(); 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 // The background group is collapsed by default, and the readable field next to the swatch is
// what a person types into. // what a person types into.
await openGroup('Hintergrund'); await openGroup('Hintergrund');
+30 -3
View File
@@ -3,6 +3,7 @@
namespace App\EventSubscriber; namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\KernelEvents;
@@ -14,12 +15,34 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array 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 public function onResponse(ResponseEvent $event): void
{ {
if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) { if (!$this->isAdmin($event->getRequest()->getPathInfo())) {
return; return;
} }
$headers = $event->getResponse()->headers; $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=()'); $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 // 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. // 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> <title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
{% include 'favicon.html.twig' %} {% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}"> <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', 'library-previews'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script> <script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
+4 -1
View File
@@ -21,12 +21,15 @@
<aside class="left-panel" aria-label="Elemente und Seitenstruktur"> <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> <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. Der Werkzeugkasten zeigt an, was an der gewählten Stelle hineinpasst — was dort nicht hingehört, sagt beim Überfahren warum.</p> <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>
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
<input id="library-search" type="search" placeholder="z. B. Galerie, Zeile, Formular …" autocomplete="off">
<p id="library-found" class="field-help" role="status"></p>
<div id="library" class="library"></div> <div id="library" class="library"></div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button> <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> <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> <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>
</div></section> </div></section>
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav> <section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element auf der Seite suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div> <div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
</section> </section>
</aside> </aside>
+25 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; 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 { 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 { readFile } from 'node:fs/promises';
import { designCss } from '../scripts/build-design.mjs'; import { designCss } from '../scripts/build-design.mjs';
@@ -492,3 +492,27 @@ test('a row dropped at an exact position brings its columns along', () => {
// Und in eine Zeile hinein gibt es keine zweite Zeile. // Und in eine Zeile hinein gibt es keine zweite Zeile.
assert.equal(insertRowAt(document, 2, row.id, 0), null); 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(', ')}`);
});