Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7225ae052e | ||
|
|
0e2334d30f | ||
|
|
22e4ada830 | ||
|
|
af5a2cef86 |
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# Abgleich mit dem Flatsome UX Builder
|
||||
|
||||
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
|
||||
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
|
||||
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
|
||||
Bedienbarkeit übernehmen und verbessern, die Oberfläche mobil nutzbar machen — der Builder
|
||||
bleibt dabei Desktop-first.
|
||||
|
||||
## 1. Wo KantSites heute schon besser ist
|
||||
|
||||
Diese Punkte sind bereits gelöst und dürfen beim Nachbau nicht verschlechtert werden.
|
||||
|
||||
| Thema | Flatsome | KantSites heute |
|
||||
| --- | --- | --- |
|
||||
| Datenhaltung | Shortcode-Text im `post_content`, per Regex geparst | Validierter Blockbaum mit stabilen IDs, versioniert (v19), Migrationsweg |
|
||||
| Gestaltung | Feste Optionslisten je Element, wenige Werte je Gerät | 93 freie Eigenschaften, je Block, je Gerät **und** für Hover |
|
||||
| Öffentliche Ausgabe | jQuery, mehrere Bundles, Slider/Tabs brauchen JS | Tabs/Slider/Fortschritt ohne JavaScript, Editorcode nicht im Frontend |
|
||||
| Entwurf/Live | Revisionen von WordPress | Entwurf und Veröffentlichung getrennt, Fassungsverlauf, geplante Veröffentlichung, Papierkorb |
|
||||
| Recht | Plugins nötig | Einwilligung mit echtem Zurückhalten, Statistik ohne Wiedererkennung, Pflichtseiten |
|
||||
| Eigener Code | `ux_html`-Element | CSS je Element für alle, Skript nur für Administratoren, als eigene Datei |
|
||||
|
||||
**Folgerung:** Der Gestaltungsteil muss nicht nachgebaut werden. Zu tun sind der
|
||||
Elementkatalog und die Bedienung.
|
||||
|
||||
## 2. Elementkatalog — Lücke
|
||||
|
||||
Flatsome hat 69 Builder-Elemente, davon 12 reine Shop-Elemente (WooCommerce).
|
||||
KantSites hat 31 Blocktypen.
|
||||
|
||||
### Bereits abgedeckt (21)
|
||||
|
||||
`section` `row` `col` `title` `text` `ux_image` `button` `ux_gallery` `blog_posts`
|
||||
`featured_box` `testimonial` `accordion` `accordion_item` `tabgroup` `tab` `contact`
|
||||
`divider` `gap` `block` `ux_menu` `ux_slider`
|
||||
|
||||
### Offen, ohne Shop (20)
|
||||
|
||||
| Flatsome | Was es kann | Bewertung |
|
||||
| --- | --- | --- |
|
||||
| `ux_banner` | Bild/Video-Hintergrund, Overlay, Höhe, Parallax, Textposition | **hoch** — das meistgenutzte Element überhaupt |
|
||||
| `text_box` | frei positionierter Textkasten über einem Banner | **hoch** — gehört zu `ux_banner` |
|
||||
| `ux_stack` | Elemente nebeneinander, mit Abstand und Ausrichtung | **hoch** — löst viele Zeile/Spalte-Verrenkungen |
|
||||
| `ux_image_box` | Bild über Text als Karte | hoch |
|
||||
| `col_grid` / `ux_banner_grid` | Rasterlayout mit ungleichen Kacheln | mittel |
|
||||
| `message_box` | Hinweiskasten mit Symbol und Farbe | mittel |
|
||||
| `price_table` | Preistabelle | mittel |
|
||||
| `team_member` | Person mit Bild, Rolle, Sozialsymbolen | mittel |
|
||||
| `ux_pages` | Unterseiten dieser Seite auflisten | mittel |
|
||||
| `ux_video` | eingebettetes Video | teilweise über `embed` |
|
||||
| `video_button` | Video im Leuchtkasten öffnen | mittel |
|
||||
| `map` | Karte | teilweise über `embed` |
|
||||
| `follow` | Sozialsymbole zum Folgen | mittel |
|
||||
| `share` | Teilen-Symbole | niedrig |
|
||||
| `search` | Suchfeld | mittel |
|
||||
| `logo` | Logo der Website als Block | hoch (Kopfbereich) |
|
||||
| `scroll_to` | Ankerziel und Sprunglink | mittel |
|
||||
| `ux_countdown` | Countdown | niedrig |
|
||||
| `ux_hotspot` | Punkte auf einem Bild | niedrig |
|
||||
| `ux_lottie` / `ux_instagram_feed` / `ux_portfolio` | Animation, Instagram, Portfolio | niedrig |
|
||||
| `ux_the_title` | dynamischer Seitentitel | mittel (Vorlagen) |
|
||||
|
||||
### Bewusst ausgelassen (12)
|
||||
|
||||
Alle WooCommerce-Elemente (`products`, `ux_products`, `ux_products_list`,
|
||||
`ux_product_categories`, `ux_product_flip`, `ux_bestseller_products`, `ux_payment_icons`,
|
||||
`ux_product_gallery`, `woocommerce_cart`, `woocommerce_checkout`, `ux_badge`,
|
||||
`custom-product`). KantSites hat keinen Shop; ohne Warenkorb, Bestellung und Zahlung
|
||||
wären diese Blöcke leere Hüllen.
|
||||
|
||||
## 3. Bedienung — die eigentliche Lücke
|
||||
|
||||
Der Auftrag nennt die Bedienbarkeit ausdrücklich. Hier ist Flatsome heute voraus.
|
||||
|
||||
| Flatsome | KantSites heute | Verbesserung |
|
||||
| --- | --- | --- |
|
||||
| Element aus der Liste **in die Vorschau ziehen**, mit Einfügemarke | Einfügen „hinter das gewählte Element“ | Ziehen mit sichtbarer Marke, **zusätzlich** die heutige Tastaturbedienung behalten |
|
||||
| **Werkzeugleiste am Element** in der Vorschau (bearbeiten, duplizieren, löschen, Elternelement) | nur über den Strukturbaum links | Leiste am Element, Auswahl bleibt mit dem Baum verbunden |
|
||||
| **Pluszeichen zwischen Elementen** | — | Einfügepunkt genau dort, wo geklickt wird |
|
||||
| Elementwahl als **Bilderraster** mit Suche | Textliste mit Symbolzeichen | Raster mit echten Vorschaubildern, Suche, Kategorien |
|
||||
| **Vorlagen je Element** mit Vorschaubild (Sektion: 20+) | 4 Textvorlagen gesamt | Vorlagen je Element, mit Bild, erweiterbar |
|
||||
| **Je Option ein Geräteschalter** | ein globaler Geräteschalter | Schalter direkt an der Option, zeigt wo ein abweichender Wert gesetzt ist |
|
||||
| **Zahl durch Ziehen ändern** (`scrubfield`) | Eingabefeld | Ziehen plus Eingabe plus Pfeiltasten |
|
||||
| **Innen-/Außenabstand als Rahmenbild** | vier Felder | Rahmenbild mit verbundenen Seiten |
|
||||
| **Spaltenbreite in der Vorschau ziehen** | Zahl eingeben | Ziehgriff zwischen Spalten |
|
||||
| Vorschau **sofort** (clientseitige Vorlagen) | Server-Neuladen, entprellt | Sofortige optimistische Anzeige, Server bleibt die Wahrheit |
|
||||
| Kopieren/Einfügen zwischen Seiten | — | Zwischenablage für Blöcke, seitenübergreifend |
|
||||
| Mobil | Builder nicht bedienbar | Verwaltung vollständig mobil; Builder mobil im Lesemodus mit Inhaltsbearbeitung |
|
||||
|
||||
## 4. Vorgeschlagene Reihenfolge
|
||||
|
||||
1. **Arbeitspaket H — Bedienung des Builders.** Ziehen und Ablegen, Werkzeugleiste am
|
||||
Element, Einfügepunkte, Bilderraster, optimistische Vorschau. Größter Nutzen je Aufwand,
|
||||
betrifft jedes vorhandene Element sofort.
|
||||
2. **Arbeitspaket I — Elementkatalog Stufe 1.** `banner`, `textbox`, `stack`, `imagebox`,
|
||||
`logo`, `messagebox`. Die Blöcke, ohne die eine Startseite Umwege braucht.
|
||||
3. **Arbeitspaket J — Optionsbedienung.** Geräteschalter je Option, Ziehfelder,
|
||||
Abstandsrahmen, Spaltenbreite ziehen, Vorlagen je Element.
|
||||
4. **Arbeitspaket K — Elementkatalog Stufe 2.** Raster, Preistabelle, Team, Unterseiten,
|
||||
Video-Leuchtkasten, Suche, Sozialsymbole, Ankersprung.
|
||||
5. **Arbeitspaket L — Mobile Oberfläche.** Verwaltung und Builder auf dem Telefon.
|
||||
|
||||
Jede Stufe endet mit einem prüfbaren Ergebnis und darf die Qualitätsziele aus AGENTS.md
|
||||
nicht verschlechtern.
|
||||
+157
-2
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 13.09.2026, Fassung **1.4.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.
|
||||
Stand: 14.09.2026, Fassung **1.5.2**. 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.
|
||||
|
||||
@@ -41,6 +41,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
||||
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
|
||||
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
|
||||
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
|
||||
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
|
||||
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
|
||||
- **Ein Fehlschlag beim Laden sagt, woran es lag** (1.4.4, ADR 070): „Die Releaseseite antwortet nicht" war drei Ursachen in einem Satz. Beim Gegenprüfen des Releases fiel auf, dass diesem PHP der **Zertifikatsspeicher** fehlt – auf einem Webhosting der häufigste Grund und der einzige, den man in einer Minute behebt. Die Meldung nennt ihn jetzt, und bei genau diesem Fehler wird der zweite Weg über den Dateizeiger versucht. Abgeschaltet wird die Prüfung nicht.
|
||||
@@ -219,9 +220,163 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Noch keine repräsentativen Kundeninhalte/Bilder/Blog-/Formularseite, keine Felddaten und keine vollständige manuelle Barrierefreiheitsprüfung.
|
||||
- Die vor der Hostingantwort erzeugte `.venv` bleibt ungenutzt und von Git ausgeschlossen. Die automatische Freigabeprüfung blockierte ihren Löschversuch mit „blocked by policy“; keine Umgehung versucht. Auch `var/fresh-check`, DB, Secrets und Berichte sind lokal ignoriert.
|
||||
|
||||
## Arbeitspaket H, Teil 1: die Vorschau wird zur Arbeitsfläche
|
||||
|
||||
**Erledigt.** Der Abgleich mit dem Flatsome UX Builder steht in `docs/FLATSOME-ABGLEICH.md`:
|
||||
die Gestaltung ist Flatsome bereits voraus (93 freie Eigenschaften je Gerät und für Hover gegen
|
||||
feste Optionslisten), die Lücken liegen beim Elementkatalog (20 offene Elemente ohne Shop) und
|
||||
bei der Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.
|
||||
|
||||
Umgesetzt ist die erste Hälfte der Bedienung:
|
||||
|
||||
- **Werkzeugleiste am Element** in der Vorschau: Name des Elements, Elternelement auswählen,
|
||||
nach oben, nach unten, duplizieren, löschen. Sie trägt den Namen des Elements
|
||||
(„Sektion duplizieren“) statt des nackten Verbs – die Seitenleiste hat dieselben fünf
|
||||
Aktionen, und zwei gleichnamige Bedienelemente auf einem Bildschirm sind für eine
|
||||
Sprachausgabe nicht unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
|
||||
- **Auswahl- und Überfahrrahmen** mit Beschriftung direkt in der Vorschau.
|
||||
- **Einfügepunkte**: ein Pluszeichen an der Ober- und Unterkante des gewählten Elements setzt
|
||||
eine Marke; das nächste Element aus dem Werkzeugkasten landet genau dort statt am Ende.
|
||||
- **Ziehen aus dem Werkzeugkasten in die Vorschau**, mit Ablegemarke zwischen den Elementen und
|
||||
Rahmen um einen Behälter, der das Element aufnimmt. Der Strukturbaum zieht in dieselbe Fläche.
|
||||
- Im Modell dafür neu: `insertBlockAt`, `moveBlockTo` und `dropList` – punktgenaues Einfügen
|
||||
und Verschieben statt „hinter das Gewählte“.
|
||||
|
||||
Die Ebene liegt im Editordokument über dem Rahmen, nicht darin: die Vorschau wird bei jeder
|
||||
Änderung vollständig ersetzt, und eingezeichnete Bedienelemente wären damit weg und stünden
|
||||
außerdem in der Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Sie trägt denselben
|
||||
Skalierungsfaktor wie der Rahmen; Leiste und Pluszeichen werden gegenskaliert und bleiben in der
|
||||
Tablet- und Mobilvorschau fingergroß.
|
||||
|
||||
**Geprüft.** `npm test` 22 von 22 (vier neue Tests für Einfügen, Verschieben, Behälterregeln und
|
||||
die oberste Ebene). `php vendor/bin/phpunit` 434 von 434. `npm run test:editor` vollständig,
|
||||
mit einem neuen Abschnitt für Werkzeugleiste, Einfügepunkt und Ziehen; alle axe-Prüfungen ohne
|
||||
Verstoß, auch die mobile Editoransicht. Screenshot unter `reports/editor/canvas-overlay.png`.
|
||||
|
||||
**Dabei gefunden und behoben:** die ganze Ebene war zunächst `aria-hidden` und enthielt
|
||||
fokussierbare Schaltflächen – axe hat das gemeldet. `canContain` las `block.type` an einem
|
||||
fehlenden Block und zerbrach beim Ziehen aus dem Werkzeugkasten, wo der Block noch nicht
|
||||
existiert. Beim Verschieben ans Listenende wurde der Index begrenzt, bevor die Entnahme
|
||||
verrechnet war; der Block landete eine Stelle zu früh.
|
||||
|
||||
**Bekannte Grenzen.** Die Vorschau wird weiterhin nach 250 ms serverseitig neu erzeugt – das
|
||||
Ablegen fühlt sich dadurch nicht so unmittelbar an wie bei Flatsome, die Wahrheit bleibt aber
|
||||
der Server. Die Werkzeugleiste ist nicht mit einem Screenreader erprobt, nur mit axe. Spalten
|
||||
lassen sich noch nicht in der Vorschau breiter ziehen, der Werkzeugkasten ist weiterhin eine
|
||||
Textliste ohne Vorschaubilder, und es gibt je Option noch keinen eigenen Geräteschalter.
|
||||
|
||||
## Arbeitspaket H, Teil 2: der Werkzeugkasten kennt den Zusammenhang
|
||||
|
||||
**Erledigt.** Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als wären sie
|
||||
Seitenanfänge. Sie gehören in den Inhalt – erst eine Sektion, dann eine Zeile darin. Das war
|
||||
nicht nur eine Anzeigefrage: eine Zeile nahm bisher **jedes** Kind an, eine Überschrift konnte
|
||||
neben den Spalten landen, und eine Spalte durfte überall stehen. Die Struktur, die der Editor in
|
||||
seinem eigenen Hinweistext beschreibt – Sektion → Zeile → Spalte → Inhalt –, galt für die
|
||||
Schaltflächen nicht.
|
||||
|
||||
Übernommen ist Flatsomes Regelmodell, weil es die Fälle abdeckt und weil es das ist, was Umut in
|
||||
den Händen hat (`inc/builder/core/server/src/Collections/Elements.php`):
|
||||
|
||||
| Regel | Flatsome | KantSites jetzt |
|
||||
| --- | --- | --- |
|
||||
| `allow` | `row => ['col']`, `accordion => ['accordion-item']` | `ALLOW` – Zeile nimmt nur Spalten |
|
||||
| `require` | `col => ['row']`, `tab => ['tabgroup']` | `REQUIRE` – Spalte lebt nur in einer Zeile |
|
||||
| — | (Flatsome kennt das nicht) | `ROOT_NEEDS_CONTAINER` – eine Zeile ist kein Seitenanfang |
|
||||
|
||||
Der dritte Punkt ist der Zusatz: Flatsome lässt eine Zeile auch lose am Dokumentanfang zu. Genau
|
||||
das ist das „aus dem Nichts“, das Umut stört, also wird es unterbunden – in jedem Behälter
|
||||
dagegen bleibt die Zeile erlaubt, auch in einem Tab oder einem Akkordeon-Eintrag.
|
||||
|
||||
Daraus folgt die Bedienung:
|
||||
|
||||
- **Der Werkzeugkasten ist nach Aufbau gruppiert** – Aufbau, Inhalt, Bausteine, Gruppen, Seite
|
||||
und Daten – statt einer flachen Liste aus 31 Typen.
|
||||
- **Was an der gewählten Stelle nicht hingehört, ist gesperrt und sagt beim Überfahren warum**
|
||||
(„Spalte: gehört in Zeile. Wähle dort zuerst ein Element aus.“). Eine Regel, die man erklärt
|
||||
bekommt, lernt man; eine Schaltfläche, die nur nicht mehr reagiert, drückt man weiter.
|
||||
- **Was möglich ist, sagt wohin es kommt** („Wird in Zeile eingefügt.“).
|
||||
- **Die Zeile wird nach Spaltenzahl angeboten** (2, 3, 4) und bringt ihre Spalten mit. Der
|
||||
separate Zeilenbereich über der Liste ist entfallen.
|
||||
- **Eine Regel für alle Wege:** `insertionTarget` beantwortet „wohin käme das?“, ohne etwas
|
||||
einzufügen. Knopf, Einfügepunkt, Ziehen und die Sperrlogik des Werkzeugkastens nutzen
|
||||
dieselbe Funktion – vorher entschied `insertBlock` allein, niemand konnte vorher fragen, und
|
||||
`insertRow` hatte eine eigene zweite Kopie derselben Platzierungslogik.
|
||||
|
||||
Ein Element, das der gewählte Behälter nicht nimmt, wird **nicht** gesperrt, sondern steigt auf,
|
||||
bis ein Elternteil es annimmt: eine Überschrift bei gewählter Zeile landet hinter der Zeile in
|
||||
der Sektion. Die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
|
||||
|
||||
**Nebenbei behoben:** `postnav` fehlte seit Schema v18 in `labels` und `icons` – der
|
||||
Strukturbaum zeigte für die Beitragsnavigation „Unbekannt“ und ein Fragezeichen.
|
||||
|
||||
**Geprüft.** `npm test` 27 von 27 (fünf neue: Zeilen-/Spaltenregel, oberste Ebene, Übereinstimmung
|
||||
von Auskunft und Tat, Kategorie-Vollständigkeit, Zeile mit Spalten an fester Stelle).
|
||||
`php vendor/bin/phpunit` 446 von 446. `npm run test:editor` vollständig mit neuem Abschnitt
|
||||
„the toolbox follows the structure“; alle axe-Prüfungen ohne Verstoß. Screenshot unter
|
||||
`reports/editor/toolbox.png`.
|
||||
|
||||
**Bekannte Grenzen.** Inhalt darf weiterhin lose am Dokumentanfang stehen – der Validator
|
||||
erlaubt es, und eine weitere Einschränkung wäre erfunden statt übernommen. Bestehende Dokumente
|
||||
werden nicht nachträglich geprüft: eine Überschrift, die früher direkt in einer Zeile gelandet
|
||||
ist, wird weiterhin ausgegeben und lässt sich auswählen und löschen, nur nicht mehr dorthin
|
||||
verschieben. Der Werkzeugkasten hat weiterhin keine Vorschaubilder und keine Suche.
|
||||
|
||||
## Arbeitspaket H, Teil 3a: die Optionen selbst
|
||||
|
||||
**Erledigt.** Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten.
|
||||
|
||||
**Geräteschalter je Option.** Bisher entschied ein einziger Schalter über der Vorschau, welches
|
||||
Gerät das ganze Bedienfeld bearbeitet. „Ist die Überschrift auf dem Telefon kleiner?“ ließ sich
|
||||
nur durch Umschalten und Hinsehen beantworten. Jetzt trägt jede Option drei Chips (D, T, M): sie
|
||||
zeigen, **wo ein eigener Wert liegt**, und ein Klick wechselt dorthin. Das ist mehr als Flatsome
|
||||
bietet – dort schaltet man um, hier sieht man vorher, ob es überhaupt etwas zu sehen gibt.
|
||||
Bedienfeld und Vorschau wechseln gemeinsam: einen Wert für ein Gerät zu setzen, das man nicht
|
||||
ansieht, ist blindes Ändern.
|
||||
|
||||
**Abstand als Rahmen.** Innenabstand, Außenabstand, Rahmenstärke und Ecken standen als vier
|
||||
Felder untereinander, beschriftet „Oben, Rechts, Unten, Links“ – man musste lesen, um zu wissen,
|
||||
welche Kante man setzt. Jetzt sitzt jedes Feld an seiner Kante, die Lage ist die Beschriftung.
|
||||
In der Mitte verkettet ein Schalter alle vier. Er startet **aus**: er ersetzt den früheren Knopf
|
||||
„Alle Seiten gleich setzen“, und das war eine bewusste Handlung. Die Einheit bleibt eine Wahl für
|
||||
die ganze Eigenschaft – vier Einheitenmenüs passen nicht in ein 240 Pixel breites Feld. Seiten
|
||||
mit unterschiedlichen Einheiten werden als „gemischt“ geführt und bleiben unangetastet, bis eine
|
||||
Einheit tatsächlich gewählt wird; ein gespeichertes 2rem wird nicht still zu 2px.
|
||||
|
||||
**Ziehfelder.** Zahlenfelder lassen sich seitwärts ziehen. „24“ tippen, schauen, „26“ tippen,
|
||||
schauen, „22“ tippen ist drei Anläufe für eine Entscheidung; Ziehen beantwortet sie in einer
|
||||
Bewegung. Umschalt grob, Alt fein. Tippen und Pfeiltasten bleiben unverändert. Nur für Maus und
|
||||
Stift: auf einem Tippgerät ist ein seitliches Ziehen über ein Feld die Art, wie das Bedienfeld
|
||||
gescrollt wird.
|
||||
|
||||
**Dabei gefunden und behoben:** Der Zug war zunächst nach einem Pixel zu Ende – jede Änderung
|
||||
zeichnete das Bedienfeld neu und ersetzte damit genau das Feld, an dem der Zeiger hing. Das
|
||||
Bedienfeld bleibt während eines Zuges stehen und wird einmal neu gezeichnet, wenn losgelassen
|
||||
wird (`keepPanel`). Der Kettenschalter verlor aus demselben Grund seinen Zustand, sobald er
|
||||
gedrückt wurde; er liegt jetzt außerhalb der Darstellung. Und das Gleichziehen nahm den Wert des
|
||||
ersten Feldes, auch wenn es leer war – ein Druck auf die Kette löschte damit die Seiten, die
|
||||
einen Wert hatten. Es nimmt jetzt den ersten vorhandenen Wert.
|
||||
|
||||
**Geprüft.** `npm test` 27 von 27. `php vendor/bin/phpunit` 452 von 452. `npm run test:editor`
|
||||
vollständig mit neuem Abschnitt „device chips, spacing box and scrub fields“: Chips zeigen den
|
||||
richtigen Zustand und wechseln Bedienfeld und Vorschau zusammen, der Rahmen ändert ohne Kette
|
||||
nur eine Seite und mit Kette alle vier, die Einheitenumstellung erhält die Zahlen, und ein Zug
|
||||
über 20 Pixel hebt 72 auf 92 und ist **ein** Undo-Schritt. Alle axe-Prüfungen ohne Verstoß.
|
||||
Screenshot unter `reports/editor/options.png`.
|
||||
|
||||
**Bekannte Grenzen.** Die Chips zeigen einen eigenen Wert, aber nicht, *welchen* – dafür muss
|
||||
man weiterhin hinwechseln. Auf Tippgeräten gibt es kein Ziehen. Der Rahmen zeigt geerbte Werte
|
||||
als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vorschaubilder und
|
||||
Suche im Werkzeugkasten, Vorlagen je Element mit Bild, und das Ziehen der Spaltenbreite in der
|
||||
Vorschau.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
|
||||
Vorschaubild und das Ziehen der Spaltenbreite in der Vorschau. Danach
|
||||
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
|
||||
Begründung in `docs/FLATSOME-ABGLEICH.md`.
|
||||
|
||||
Unverändert offen: den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
|
||||
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The layer above the preview.
|
||||
//
|
||||
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
|
||||
// whole document. Anything drawn inside it would therefore be wiped on the next keystroke and
|
||||
// would also end up in the markup we are supposed to be showing faithfully. So the outline, the
|
||||
// element toolbar, the insert points and the drop marker all live in the editor document, in an
|
||||
// absolutely positioned layer that sits exactly on top of the frame.
|
||||
//
|
||||
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
|
||||
// rectangle measured inside the frame can be used here unchanged. Measurements come from
|
||||
// getBoundingClientRect, which is relative to the frame's own viewport - that is why scrolling
|
||||
// the preview only needs a re-measure and no offset arithmetic.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Diameter of a plus sign, in the editor's own pixels. */
|
||||
const INSERT_SIZE = 22;
|
||||
|
||||
/**
|
||||
* What the toolbar offers, in the order it appears.
|
||||
*
|
||||
* Each label names the element it acts on - "Sektion duplizieren", not "Duplizieren". The
|
||||
* sidebar carries the same five actions, and two controls with the same name on one screen are
|
||||
* indistinguishable to anyone listening rather than looking. Naming the subject also says more
|
||||
* than the generic verb did: the toolbar floats next to the element, the announcement should
|
||||
* carry that context instead of assuming it was seen.
|
||||
*/
|
||||
const TOOLS = [
|
||||
{ action: 'parent', glyph: '⤴', title: label => `Element über ${label} auswählen` },
|
||||
{ action: 'up', glyph: '↑', title: label => `${label} nach oben verschieben` },
|
||||
{ action: 'down', glyph: '↓', title: label => `${label} nach unten verschieben` },
|
||||
{ action: 'duplicate', glyph: '⧉', title: label => `${label} duplizieren` },
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
const hoverLabel = element('span', 'ov-label');
|
||||
hover.append(hoverLabel);
|
||||
const select = element('div', 'ov-box ov-select');
|
||||
const selectName = document.createElement('span');
|
||||
selectName.className = 'ov-toolbar-name';
|
||||
selectName.setAttribute('aria-hidden', 'true');
|
||||
const toolbar = element('div', 'ov-toolbar');
|
||||
toolbar.removeAttribute('aria-hidden');
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
|
||||
const drop = element('div', 'ov-drop');
|
||||
const insertBefore = insertButton('before', 'Element davor einfügen');
|
||||
const insertAfter = insertButton('after', 'Element dahinter einfügen');
|
||||
toolbar.append(selectName);
|
||||
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop);
|
||||
stage.append(layer);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `ov-tool${tool.danger ? ' ov-tool-danger' : ''}`;
|
||||
button.dataset.action = tool.action;
|
||||
button.textContent = tool.glyph;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onAction(tool.action, getSelected()));
|
||||
toolbar.append(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* A part that only draws something. The whole layer must not be aria-hidden - it carries real
|
||||
* buttons, and a focusable control inside an aria-hidden subtree is exactly the contradiction
|
||||
* screen readers cannot resolve. So the decoration is hidden one piece at a time.
|
||||
*/
|
||||
function element(tag, className) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
node.setAttribute('aria-hidden', 'true');
|
||||
node.hidden = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
function insertButton(where, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-insert';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
button.textContent = '+';
|
||||
button.hidden = true;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onInsert(getSelected(), where));
|
||||
return button;
|
||||
}
|
||||
|
||||
let hovered = null, dragging = null, dropPlan = null;
|
||||
|
||||
const doc = () => frame.contentDocument;
|
||||
/** Is this id a block of the current document rather than some id in the markup? */
|
||||
const known = id => Boolean(id) && Boolean(locate(getDocument().blocks, id));
|
||||
|
||||
/** The nearest element that stands for a block of the document. */
|
||||
function blockElement(node) {
|
||||
for (let current = node; current && current !== doc(); current = current.parentElement) {
|
||||
if (current.id && known(current.id)) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rectOf(id) {
|
||||
const node = id ? doc()?.getElementById(id) : null;
|
||||
if (!node) return null;
|
||||
const box = node.getBoundingClientRect();
|
||||
return box.width || box.height ? box : null;
|
||||
}
|
||||
|
||||
/** Puts a box on a measured rectangle, or hides it when the block is not on screen. */
|
||||
function place(node, box, inset = 0) {
|
||||
if (!box) { node.hidden = true; return false; }
|
||||
node.hidden = false;
|
||||
node.style.left = `${box.left + inset}px`;
|
||||
node.style.top = `${box.top + inset}px`;
|
||||
node.style.width = `${Math.max(0, box.width - inset * 2)}px`;
|
||||
node.style.height = `${Math.max(0, box.height - inset * 2)}px`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the chrome legible when the preview is scaled down. A tablet preview on a narrow
|
||||
* screen runs at about 0.6; without this the toolbar would shrink with it and the buttons
|
||||
* would fall under the size a finger or an eye can deal with.
|
||||
*/
|
||||
function counterScale(node, originX, originY) {
|
||||
const scale = getScale();
|
||||
node.style.transformOrigin = `${originX} ${originY}`;
|
||||
node.style.transform = scale < 1 ? `scale(${1 / scale})` : '';
|
||||
}
|
||||
|
||||
function positionToolbar(box, hasInsert) {
|
||||
if (!box) { toolbar.hidden = true; return; }
|
||||
toolbar.hidden = false;
|
||||
const scale = getScale();
|
||||
// The toolbar is drawn at its natural size and then counter-scaled, so the room it needs
|
||||
// inside the frame's coordinates shrinks as the preview shrinks.
|
||||
const needed = toolbar.offsetHeight / scale;
|
||||
// The upper plus sign straddles the top edge. On a narrow element - a button, a heading of
|
||||
// two words - it sits under the middle of the toolbar, so the toolbar makes room for it.
|
||||
const clearance = hasInsert ? INSERT_SIZE / scale / 2 : 0;
|
||||
const above = box.top >= needed + clearance + 2;
|
||||
toolbar.style.left = `${box.left}px`;
|
||||
toolbar.style.top = `${above
|
||||
? box.top - needed - clearance - 2
|
||||
: Math.min(box.bottom + clearance + 2, frame.clientHeight - needed)}px`;
|
||||
counterScale(toolbar, 'left', 'top');
|
||||
}
|
||||
|
||||
function positionInsert(button, box, where) {
|
||||
if (!box) { button.hidden = true; return; }
|
||||
const scale = getScale();
|
||||
const size = INSERT_SIZE / scale;
|
||||
button.hidden = false;
|
||||
button.style.left = `${box.left + box.width / 2 - size / 2}px`;
|
||||
button.style.top = `${(where === 'before' ? box.top : box.bottom) - size / 2}px`;
|
||||
button.style.width = `${size}px`;
|
||||
button.style.height = `${size}px`;
|
||||
counterScale(button, 'center', 'center');
|
||||
}
|
||||
|
||||
/** Renames the toolbar for whatever is selected right now. */
|
||||
function nameTools(id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
const label = block ? blockLabel(block) : 'Element';
|
||||
selectName.textContent = label;
|
||||
for (const button of toolbar.children) {
|
||||
const tool = TOOLS.find(entry => entry.action === button.dataset.action);
|
||||
if (!tool) continue;
|
||||
button.title = tool.title(label);
|
||||
button.setAttribute('aria-label', tool.title(label));
|
||||
}
|
||||
}
|
||||
|
||||
function paintLabel(node, id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
node.textContent = block ? blockLabel(block) : '';
|
||||
counterScale(node, 'left', 'bottom');
|
||||
}
|
||||
|
||||
/** Re-measures everything. Cheap enough to call on every scroll frame. */
|
||||
function sync() {
|
||||
const scale = getScale();
|
||||
layer.style.width = `${frame.clientWidth}px`;
|
||||
layer.style.height = `${frame.clientHeight}px`;
|
||||
layer.style.transform = `scale(${scale})`;
|
||||
if (!doc()) return;
|
||||
const selectedId = getSelected();
|
||||
const selectedBox = rectOf(selectedId);
|
||||
place(select, selectedBox);
|
||||
if (selectedBox) nameTools(selectedId);
|
||||
// Insert points only make sense for a block that has somewhere to sit next to.
|
||||
const item = selectedId ? locate(getDocument().blocks, selectedId) : null;
|
||||
const sibling = item && dropList(getDocument(), item.parent?.id ?? null, item.block);
|
||||
positionInsert(insertBefore, sibling ? selectedBox : null, 'before');
|
||||
positionInsert(insertAfter, sibling ? selectedBox : null, 'after');
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) paintDrop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** The child elements of a block, in document order, that stand for its children. */
|
||||
function childElements(id) {
|
||||
const block = id ? locate(getDocument().blocks, id)?.block : null;
|
||||
const children = id ? (block?.children ?? []) : getDocument().blocks;
|
||||
return children.map(child => ({ id: child.id, box: rectOf(child.id) })).filter(entry => entry.box);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land inside a container. Children that sit side by side - the columns of
|
||||
* a row, the images of a gallery - are compared left to right, everything else top to bottom,
|
||||
* so the insertion line always runs across the gap the pointer is actually in.
|
||||
*/
|
||||
function indexInside(parentId, x, y) {
|
||||
const kids = childElements(parentId);
|
||||
if (!kids.length) return { index: 0, line: null };
|
||||
const sideBySide = kids.length > 1 && kids.some(kid => kid.box.left >= kids[0].box.right - 1);
|
||||
for (let index = 0; index < kids.length; index++) {
|
||||
const box = kids[index].box;
|
||||
const middle = sideBySide ? box.left + box.width / 2 : box.top + box.height / 2;
|
||||
if ((sideBySide ? x : y) < middle) return { index, line: { box, edge: sideBySide ? 'left' : 'top' }, sideBySide };
|
||||
}
|
||||
const last = kids[kids.length - 1].box;
|
||||
return { index: kids.length, line: { box: last, edge: sideBySide ? 'right' : 'bottom' }, sideBySide };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pointer position into a concrete destination. The rule, from the inside out: land
|
||||
* inside the hovered block when it is a container that takes this kind of child, otherwise
|
||||
* next to it, otherwise try the same two answers one level up.
|
||||
*/
|
||||
function planDrop(x, y, block) {
|
||||
const document_ = getDocument();
|
||||
if (x < 0 || y < 0 || x > frame.clientWidth || y > frame.clientHeight) return null;
|
||||
const start = blockElement(doc()?.elementFromPoint(x, y));
|
||||
for (let node = start; ; ) {
|
||||
const id = node?.id ?? null;
|
||||
const item = id ? locate(document_.blocks, id) : null;
|
||||
// Never offer to drop a block into itself or into its own subtree.
|
||||
const ownBranch = block && id && (id === block.id || Boolean(locate(block.children || [], id)));
|
||||
if (id && !ownBranch && isContainer(item?.block) && dropList(document_, id, block)) {
|
||||
const inside = indexInside(id, x, y);
|
||||
return { parentId: id, index: inside.index, line: inside.line ?? { box: rectOf(id), edge: 'inside' } };
|
||||
}
|
||||
const parentId = item?.parent?.id ?? null;
|
||||
if (id && !ownBranch && dropList(document_, parentId, block)) {
|
||||
const box = rectOf(id);
|
||||
const after = box ? y > box.top + box.height / 2 : false;
|
||||
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: after ? 'bottom' : 'top' } : null };
|
||||
}
|
||||
if (!node) break;
|
||||
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
|
||||
if (!node) {
|
||||
// Fell out of the tree: the top level is the last thing left to try.
|
||||
if (!dropList(document_, null, block)) break;
|
||||
const inside = indexInside(null, x, y);
|
||||
return { parentId: null, index: inside.index, line: inside.line };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paintDrop() {
|
||||
const line = dropPlan?.line;
|
||||
if (!line?.box) { drop.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
const scale = getScale();
|
||||
const thickness = 3 / scale;
|
||||
const { box, edge } = line;
|
||||
drop.classList.toggle('ov-drop-inside', edge === 'inside');
|
||||
if (edge === 'inside') {
|
||||
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
|
||||
drop.style.width = `${box.width}px`; drop.style.height = `${box.height}px`;
|
||||
return;
|
||||
}
|
||||
const vertical = edge === 'left' || edge === 'right';
|
||||
drop.style.left = `${edge === 'right' ? box.right : box.left}px`;
|
||||
drop.style.top = `${edge === 'bottom' ? box.bottom : box.top}px`;
|
||||
drop.style.width = `${vertical ? thickness : box.width}px`;
|
||||
drop.style.height = `${vertical ? box.height : thickness}px`;
|
||||
if (vertical) drop.style.marginLeft = `${-thickness / 2}px`; else drop.style.marginLeft = '0';
|
||||
if (!vertical) drop.style.marginTop = `${-thickness / 2}px`; else drop.style.marginTop = '0';
|
||||
}
|
||||
|
||||
/** Attaches to a freshly loaded preview document. Called after every preview refresh. */
|
||||
function attach() {
|
||||
const document_ = doc();
|
||||
if (!document_) return;
|
||||
document_.addEventListener('mousemove', event => {
|
||||
if (dragging) return;
|
||||
const node = blockElement(event.target);
|
||||
const id = node?.id ?? null;
|
||||
if (id === hovered) return;
|
||||
hovered = id;
|
||||
sync();
|
||||
});
|
||||
document_.documentElement.addEventListener('mouseleave', () => { hovered = null; sync(); });
|
||||
document_.addEventListener('scroll', sync, { passive: true, capture: true });
|
||||
document_.defaultView?.addEventListener('scroll', sync, { passive: true });
|
||||
// Inside the frame an event already carries frame coordinates, and those are exactly the
|
||||
// coordinates the layer draws in - no conversion, and none of the rounding a trip through
|
||||
// the editor viewport and back through the scale factor would add.
|
||||
document_.addEventListener('dragover', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragging.kind === 'move' ? 'move' : 'copy';
|
||||
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
paintDrop();
|
||||
});
|
||||
document_.addEventListener('drop', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
// Both are read before the clean-up clears them.
|
||||
const payload = dragging, plan = dropPlan;
|
||||
endDrag();
|
||||
if (plan) onDrop(payload, plan);
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
function startDrag(payload) {
|
||||
dragging = payload;
|
||||
layer.classList.add('is-dragging');
|
||||
hovered = null;
|
||||
sync();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragging = null;
|
||||
dropPlan = null;
|
||||
drop.hidden = true;
|
||||
layer.classList.remove('is-dragging');
|
||||
sync();
|
||||
}
|
||||
|
||||
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
|
||||
}
|
||||
+225
-33
@@ -44,6 +44,58 @@ export function contrastRatio(foreground, background) {
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a number field draggable sideways, the way the Flatsome scrubfield works.
|
||||
*
|
||||
* Typing "24" into a field to see whether 24 is right, then "26", then "22", is three round
|
||||
* trips for one decision. Dragging answers it in one motion. Typing and the arrow keys keep
|
||||
* working untouched - this is added, nothing is taken away.
|
||||
*
|
||||
* Only for mouse and pen: on a touch screen a horizontal drag over a field is how the panel is
|
||||
* scrolled, and stealing that would make the panel unusable on a phone.
|
||||
*/
|
||||
function makeScrubbable(input, { apply, step, mergeKey }) {
|
||||
const size = 'any' === step || !step ? 1 : Number(step);
|
||||
input.classList.add('scrubbable');
|
||||
let startX = 0, startValue = 0, active = false, moved = false;
|
||||
input.addEventListener('pointerdown', event => {
|
||||
if (0 !== event.button || input.disabled || 'touch' === event.pointerType) return;
|
||||
startX = event.clientX;
|
||||
startValue = '' === input.value ? 0 : Number(input.value);
|
||||
active = Number.isFinite(startValue);
|
||||
moved = false;
|
||||
});
|
||||
input.addEventListener('pointermove', event => {
|
||||
if (!active) return;
|
||||
const distance = event.clientX - startX;
|
||||
// A few pixels of slack, so a plain click still lands in the field to type in.
|
||||
if (!moved && Math.abs(distance) < 3) return;
|
||||
if (!moved) { moved = true; input.setPointerCapture(event.pointerId); }
|
||||
event.preventDefault();
|
||||
// Shift is coarse, Alt is fine - the two speeds a value needs while being judged by eye.
|
||||
const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
|
||||
const raw = startValue + distance * size * scale;
|
||||
const rounded = Math.round(raw / (size * scale)) * (size * scale);
|
||||
const min = '' === input.min ? -Infinity : Number(input.min);
|
||||
const max = '' === input.max ? Infinity : Number(input.max);
|
||||
const value = Math.min(max, Math.max(min, Math.round(rounded * 1000) / 1000));
|
||||
if (String(value) === input.value) return;
|
||||
input.value = String(value);
|
||||
apply(String(value), mergeKey);
|
||||
});
|
||||
const stop = event => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (!moved) return;
|
||||
if (input.hasPointerCapture?.(event.pointerId)) input.releasePointerCapture(event.pointerId);
|
||||
// The drag was one decision, so it closes as one entry in the history.
|
||||
apply(input.value, null);
|
||||
};
|
||||
input.addEventListener('pointerup', stop);
|
||||
input.addEventListener('pointercancel', stop);
|
||||
input.title = 'Zum Ändern seitwärts ziehen. Umschalt grob, Alt fein.';
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -51,8 +103,23 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
|
||||
const change = mutate => { getHistory().change(mutate); onChange(); };
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
|
||||
/**
|
||||
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
|
||||
*
|
||||
* `keepPanel` is what makes dragging possible at all: a change normally re-renders this panel,
|
||||
* which replaces the very field the pointer is holding on to. The drag would end after its
|
||||
* first pixel. While scrubbing, the document and the preview are updated and the panel is left
|
||||
* standing; it is redrawn once, when the pointer is released.
|
||||
*/
|
||||
const change = (mutate, mergeKey, keepPanel = false) => {
|
||||
getHistory().change(mutate, mergeKey);
|
||||
onChange({ keepPanel });
|
||||
};
|
||||
// Whether the sides of a property move together. This has to outlive the panel: switching the
|
||||
// chain triggers a change, a change re-renders the panel, and a state kept in the button alone
|
||||
// would be gone by the time the next keystroke arrives.
|
||||
const linkedSides = new Map();
|
||||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||||
let clipboard = null;
|
||||
|
||||
@@ -73,13 +140,54 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
}
|
||||
};
|
||||
|
||||
/** Short marks for the chips; the full device name is in the title and the accessible name. */
|
||||
const SHORT = { desktop: 'D', tablet: 'T', mobile: 'M' };
|
||||
|
||||
/** Does this device carry an own value for this property - as a plain value or per side? */
|
||||
const hasOwn = (block, deviceName, key, hover) => {
|
||||
const bucket = hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
|
||||
const value = bucket?.[key];
|
||||
if (value === undefined) return false;
|
||||
// A per-side property stores an object; an empty one is a leftover, not a value.
|
||||
return value && typeof value === 'object' ? Object.values(value).some(single => single !== '' && single !== undefined) : value !== '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Three chips per option showing which devices carry an own value, and switching to one.
|
||||
*
|
||||
* Until now a single switch above the preview decided which device the whole panel edited,
|
||||
* so "is the heading smaller on the phone" could only be answered by switching and looking.
|
||||
* The chips answer it where the question comes up. Clicking one switches the preview as well:
|
||||
* editing a value for a device you are not looking at is editing blind.
|
||||
*/
|
||||
const deviceChips = (block, key, labelText, hover) => {
|
||||
const current = getDevice();
|
||||
const group = element('div', 'device-chips');
|
||||
group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-label', `Gerät für ${labelText}`);
|
||||
for (const [name, deviceLabel] of Object.entries(devices)) {
|
||||
const own = hasOwn(block, name, key, hover);
|
||||
const chip = element('button', 'device-chip' + (own ? ' device-chip-own' : ''), SHORT[name]);
|
||||
chip.type = 'button';
|
||||
chip.dataset.device = name;
|
||||
chip.setAttribute('aria-pressed', String(name === current));
|
||||
const state = own ? 'eigener Wert' : 'erbt';
|
||||
chip.title = `${deviceLabel} – ${state}`;
|
||||
chip.setAttribute('aria-label', `${labelText} für ${deviceLabel} bearbeiten – ${state}`);
|
||||
chip.addEventListener('click', () => { if (name !== current) onDevice(name); });
|
||||
group.append(chip);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
/** One labelled row with a reset button that appears as soon as this device has an own value. */
|
||||
const row = (id, labelText, control, { own, inherited, onReset }) => {
|
||||
const row = (id, labelText, control, { own, inherited, onReset, chips }) => {
|
||||
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
|
||||
const head = element('div', 'design-row-head');
|
||||
const label = element('label', null, labelText);
|
||||
label.htmlFor = id;
|
||||
head.append(label);
|
||||
if (chips) head.append(chips);
|
||||
if (own) {
|
||||
const reset = element('button', 'text-button design-reset-one', '⟲');
|
||||
reset.type = 'button';
|
||||
@@ -132,9 +240,10 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
unit.setAttribute('aria-label', 'Einheit');
|
||||
for (const name of UNITS) unit.append(new Option(name, name));
|
||||
unit.value = parts.unit;
|
||||
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
|
||||
number.addEventListener('change', push);
|
||||
const push = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
|
||||
number.addEventListener('change', () => push());
|
||||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||||
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
|
||||
wrap.append(number, unit);
|
||||
return wrap;
|
||||
};
|
||||
@@ -155,6 +264,11 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
slider.addEventListener('input', () => { exact.value = slider.value; });
|
||||
slider.addEventListener('change', () => apply(slider.value));
|
||||
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
|
||||
makeScrubbable(exact, {
|
||||
apply: (value, mergeKey) => { slider.value = value; apply(value, mergeKey); },
|
||||
step: property.step,
|
||||
mergeKey: `scrub-${id}`,
|
||||
});
|
||||
wrap.append(slider, exact);
|
||||
return wrap;
|
||||
};
|
||||
@@ -179,35 +293,110 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return select;
|
||||
};
|
||||
|
||||
/** Four inputs for padding, margin, border width and corners. */
|
||||
/**
|
||||
* Padding, margin, border width and corners as a box.
|
||||
*
|
||||
* Four stacked fields labelled "Oben, Rechts, Unten, Links" made you read to find out which
|
||||
* edge you were setting. In a box each field sits on the edge it controls, so the position is
|
||||
* the label. The chain in the middle keeps all four equal, which is what most of them are.
|
||||
*
|
||||
* The unit stays a single choice for the whole property, because four unit menus do not fit a
|
||||
* 240-pixel panel. Sides that already carry different units are shown as "gemischt" and are
|
||||
* left alone until a unit is actually picked - a stored 2rem is not silently turned into 2px.
|
||||
*/
|
||||
const sideControl = (key, block, deviceName, property) => {
|
||||
const grid = element('div', 'side-control');
|
||||
const parts = property.corners ? schema.corners : schema.sides;
|
||||
for (const [side, label] of Object.entries(parts)) {
|
||||
const cell = element('div', 'side-cell');
|
||||
const id = `style-${key}-${side}`;
|
||||
const caption = element('label', null, label);
|
||||
caption.htmlFor = id;
|
||||
const corners = Boolean(property.corners);
|
||||
const parts = corners ? schema.corners : schema.sides;
|
||||
const names = Object.keys(parts);
|
||||
const wrap = element('div', 'side-wrap');
|
||||
const box = element('div', 'side-box' + (corners ? ' side-box-corners' : ''));
|
||||
|
||||
// What is stored right now, per side, split into number and unit.
|
||||
const stored = Object.fromEntries(names.map(side => {
|
||||
const current = effectiveStyle(block, deviceName, key, side);
|
||||
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
|
||||
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
|
||||
}, current.value ? String(current.value) : 'erbt');
|
||||
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
|
||||
cell.append(caption, control);
|
||||
grid.append(cell);
|
||||
}
|
||||
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
|
||||
all.type = 'button';
|
||||
all.addEventListener('click', () => {
|
||||
const first = grid.querySelector('input[type=number]');
|
||||
const unit = grid.querySelector('select');
|
||||
if (first?.value === '') return;
|
||||
const own = current.source === deviceName ? current.value : '';
|
||||
return [side, { own, inherited: current.value, parts: splitLength(own) }];
|
||||
}));
|
||||
const usedUnits = [...new Set(names.map(side => stored[side].own ? stored[side].parts.unit : null).filter(Boolean))];
|
||||
const commonUnit = 1 === usedUnits.length ? usedUnits[0] : '';
|
||||
|
||||
const unit = document.createElement('select');
|
||||
unit.id = `style-${key}-unit`;
|
||||
unit.className = 'side-unit-select';
|
||||
unit.setAttribute('aria-label', `Einheit für ${property.label}`);
|
||||
if (usedUnits.length > 1) unit.append(new Option('gemischt', ''));
|
||||
for (const name of UNITS) unit.append(new Option(name, name));
|
||||
unit.value = commonUnit || 'px';
|
||||
|
||||
const linked = element('button', 'side-link', '⛓');
|
||||
linked.type = 'button';
|
||||
// Off to begin with. It replaces the old "Alle Seiten gleich setzen" button, and that was a
|
||||
// deliberate act: typing into one field must not quietly rewrite the three others.
|
||||
linked.setAttribute('aria-pressed', String(linkedSides.get(key) ?? false));
|
||||
linked.title = 'Alle Seiten gemeinsam ändern';
|
||||
linked.setAttribute('aria-label', `Alle Seiten von ${property.label} gemeinsam ändern`);
|
||||
|
||||
/** The unit a side should be written with: its own, else the chosen one. */
|
||||
const unitFor = side => (stored[side].own ? stored[side].parts.unit : null) ?? (unit.value || 'px');
|
||||
|
||||
const write = (side, number, mergeKey) => {
|
||||
const targets = linkedSides.get(key) ? names : [side];
|
||||
change(doc => {
|
||||
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
|
||||
for (const target of targets) {
|
||||
setStyle(doc, getSelection(), deviceName, key, '' === number ? '' : `${number}${unitFor(target)}`, { side: target });
|
||||
}
|
||||
}, mergeKey, Boolean(mergeKey));
|
||||
};
|
||||
|
||||
for (const side of names) {
|
||||
const id = `style-${key}-${side}`;
|
||||
const field = document.createElement('input');
|
||||
field.type = 'number';
|
||||
field.step = 'any';
|
||||
field.id = id;
|
||||
field.className = 'side-field';
|
||||
field.value = stored[side].parts.number;
|
||||
field.placeholder = stored[side].inherited ? String(stored[side].inherited) : 'erbt';
|
||||
field.setAttribute('aria-label', `${property.label} ${parts[side]}`);
|
||||
if (stored[side].inherited && !stored[side].own) field.classList.add('is-inherited');
|
||||
field.addEventListener('change', () => write(side, field.value, null));
|
||||
makeScrubbable(field, { apply: (value, mergeKey) => write(side, value, mergeKey), step: 1, mergeKey: `scrub-${id}` });
|
||||
const cell = element('div', `side-cell side-${side}`);
|
||||
cell.append(field);
|
||||
box.append(cell);
|
||||
}
|
||||
box.append(linked);
|
||||
|
||||
linked.addEventListener('click', () => {
|
||||
const now = !linkedSides.get(key);
|
||||
linkedSides.set(key, now);
|
||||
linked.setAttribute('aria-pressed', String(now));
|
||||
if (!now) return;
|
||||
// Switching the chain on levels the sides - otherwise it would claim a state the box does
|
||||
// not show. The value taken is the first one that exists: starting from an empty top field
|
||||
// would wipe the sides that do have a value.
|
||||
const set = names.map(side => stored[side].parts.number).find(number => '' !== number);
|
||||
if (undefined === set) return;
|
||||
change(doc => {
|
||||
for (const target of names) setStyle(doc, getSelection(), deviceName, key, `${set}${unit.value || 'px'}`, { side: target });
|
||||
});
|
||||
});
|
||||
const wrap = element('div', 'side-wrap');
|
||||
wrap.append(grid, all);
|
||||
|
||||
unit.addEventListener('change', () => {
|
||||
if ('' === unit.value) return;
|
||||
change(doc => {
|
||||
for (const side of names) {
|
||||
const number = stored[side].own ? stored[side].parts.number : '';
|
||||
if ('' !== number) setStyle(doc, getSelection(), deviceName, key, `${number}${unit.value}`, { side });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unitRow = element('div', 'side-unit');
|
||||
const unitLabel = element('label', null, 'Einheit');
|
||||
unitLabel.htmlFor = unit.id;
|
||||
unitRow.append(unitLabel, unit);
|
||||
wrap.append(box, unitRow);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -307,7 +496,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
const id = `style-${key}`;
|
||||
const current = effectiveStyle(block, deviceName, key);
|
||||
const own = block.style?.[deviceName]?.[key] !== undefined;
|
||||
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
|
||||
const apply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value), mergeKey, Boolean(mergeKey));
|
||||
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
|
||||
const inheritedText = current.value !== undefined && !own
|
||||
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
|
||||
@@ -315,7 +504,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
let control;
|
||||
if (property.sides || property.corners) {
|
||||
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
|
||||
const sideHead = element('div', 'design-row-head design-side-head');
|
||||
sideHead.append(element('p', 'design-group-label', property.label), deviceChips(block, key, property.label, false));
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
@@ -326,12 +517,12 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
: property.type === 'number'
|
||||
@@ -341,6 +532,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
own: hoverValue !== undefined,
|
||||
inherited: 'Kein eigener Hover-Wert',
|
||||
onReset: () => hoverApply(''),
|
||||
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+163
-21
@@ -1,7 +1,34 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
* The toolbox, grouped the way the page is actually built. A flat list of thirty-one types said
|
||||
* nothing about which of them belong together or where they may go; the first group is the
|
||||
* scaffolding, the rest is what goes inside it.
|
||||
*/
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
export const ROW_COLUMNS = [2, 3, 4];
|
||||
|
||||
/**
|
||||
* Why a type cannot be added where the selection currently is. The toolbox says this instead of
|
||||
* simply greying a button out: a rule you are told is one you learn, a button that merely stops
|
||||
* working is one you keep pressing.
|
||||
*/
|
||||
export function whyNot(type) {
|
||||
if (REQUIRE[type]) return `${labels[type]}: gehört in ${REQUIRE[type].map(parent => labels[parent]).join(' oder ')}. Wähle dort zuerst ein Element aus.`;
|
||||
if (ROOT_NEEDS_CONTAINER.includes(type)) return `${labels[type]}: gehört in eine Sektion. Füge zuerst eine Sektion ein oder wähle eine aus.`;
|
||||
return `${labels[type]}: passt an dieser Stelle nicht. Wähle ein anderes Element aus.`;
|
||||
}
|
||||
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
|
||||
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
|
||||
@@ -9,11 +36,37 @@ export const blockIcon = block => icons[block?.type] ?? '❔';
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
|
||||
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
||||
// Containers that accept only one kind of child, mirrored by the server side validator.
|
||||
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What may sit inside what.
|
||||
//
|
||||
// Three rules, the same three the Flatsome UX Builder uses, because they cover the cases and
|
||||
// because they are the ones people already have in their hands:
|
||||
//
|
||||
// ALLOW a container that takes only these children and nothing else
|
||||
// REQUIRE a block that exists only inside one of these parents
|
||||
// ROOT_NEEDS_CONTAINER
|
||||
// a block that may sit in any container but not loose at the top of a document
|
||||
//
|
||||
// Until now a row took anything - a heading could land directly in it, next to the columns -
|
||||
// and a column could stand anywhere. That is why the toolbox offered rows and columns out of
|
||||
// nowhere. The structure the editor has always described in its own hint text is
|
||||
// section -> row -> column -> content, and these rules are that sentence made binding.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/** Blocks that exist only inside one of these parents. */
|
||||
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
* a section, and that is where a row belongs.
|
||||
*/
|
||||
export const ROOT_NEEDS_CONTAINER = ['row'];
|
||||
|
||||
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
||||
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
|
||||
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
|
||||
&& (block.type !== 'tabitem' || parent.type === 'tabs');
|
||||
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
|
||||
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
export function locate(blocks, id, parent = null) {
|
||||
@@ -59,8 +112,8 @@ export function createBlock(type) {
|
||||
return block;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
@@ -72,13 +125,37 @@ export function insertBlock(document, type, selected) {
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
if (!target) document.blocks.push(block);
|
||||
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
|
||||
else target.siblings.splice(target.index + 1, 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where clicking a type in the toolbox would put it - without putting it anywhere.
|
||||
*
|
||||
* Asking and doing used to be the same step, so nothing could tell beforehand whether a type
|
||||
* fits at all. The toolbox needs exactly that answer to grey out what does not belong here, and
|
||||
* the answer has to be the one the click will actually follow, or the two drift apart.
|
||||
*
|
||||
* The search goes from the inside out: into the selection when it is a container that takes
|
||||
* this type, otherwise next to it, climbing until some ancestor accepts it, and finally the top
|
||||
* level. Returns null when the type cannot go anywhere from this selection.
|
||||
*
|
||||
* @return {{parentId: string|null, index: number}|null}
|
||||
*/
|
||||
export function insertionTarget(document, selected, type) {
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
while (item) {
|
||||
const parentId = item.parent?.id ?? null;
|
||||
if (dropList(document, parentId, probe)) return { parentId, index: item.index + 1 };
|
||||
item = item.parent ? locate(document.blocks, item.parent.id) : null;
|
||||
}
|
||||
return dropList(document, null, probe) ? { parentId: null, index: document.blocks.length } : null;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const target = insertionTarget(document, selected, type);
|
||||
if (!target) return null;
|
||||
return insertBlockAt(document, type, target.parentId, target.index);
|
||||
}
|
||||
|
||||
export function duplicateBlock(document, selected) {
|
||||
@@ -109,6 +186,60 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a block may land. `parentId` of null means the top level. Returns the list that would
|
||||
* hold the block, or null when the parent refuses this kind of child - a gallery takes images
|
||||
* and nothing else, an accordion takes entries.
|
||||
*/
|
||||
export function dropList(document, parentId, block) {
|
||||
if (parentId === null) {
|
||||
const type = block?.type;
|
||||
const loose = type !== undefined && (REQUIRE[type] !== undefined || ROOT_NEEDS_CONTAINER.includes(type));
|
||||
return loose ? null : document.blocks;
|
||||
}
|
||||
const parent = locate(document.blocks, parentId)?.block;
|
||||
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
|
||||
}
|
||||
|
||||
/** Puts an already built block at an exact position, or refuses when the parent will not take it. */
|
||||
function place(document, block, parentId, index) {
|
||||
const list = dropList(document, parentId, block);
|
||||
if (!list) return null;
|
||||
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/** Inserts a new block at an exact position instead of behind the selection. */
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
upgradeForType(document, type);
|
||||
return place(document, createBlock(type), parentId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
|
||||
* block dragged between two others lands between them and not at the end of the container.
|
||||
*/
|
||||
export function moveBlockTo(document, selected, parentId, index) {
|
||||
const source = locate(document.blocks, selected);
|
||||
if (!source) return false;
|
||||
// Dropping a container into itself or into its own child would detach the branch.
|
||||
if (parentId === selected || (parentId !== null && locate(source.block.children || [], parentId))) return false;
|
||||
if (!dropList(document, parentId, source.block)) return false;
|
||||
const from = source.siblings, fromIndex = source.index;
|
||||
from.splice(fromIndex, 1);
|
||||
// Re-read: removing the block may have shifted the target list or emptied a parent.
|
||||
const list = dropList(document, parentId, source.block);
|
||||
if (!list) { from.splice(fromIndex, 0, source.block); return false; }
|
||||
// The index still counts in the list as it was before the block was taken out, so the shift
|
||||
// has to be settled first and only then clamped - the other way round an index pointing past
|
||||
// the end is clamped to the old length and then pulled one step too far forward.
|
||||
let at = index;
|
||||
if (list === from && fromIndex < index) at -= 1;
|
||||
at = Math.max(0, Math.min(at, list.length));
|
||||
list.splice(at, 0, source.block);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
@@ -227,18 +358,29 @@ export function insertHero(document, selected) {
|
||||
return section.id;
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
/** A row with its columns already in it. On its own an empty row is a box with nothing in it. */
|
||||
function buildRow(columns) {
|
||||
const count = Math.max(1, Math.min(6, Number(columns) || 2));
|
||||
const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' };
|
||||
const row = createBlock('row');
|
||||
row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } };
|
||||
row.children = Array.from({ length: count }, () => createBlock('column'));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
||||
export function insertRowAt(document, columns, parentId, index) {
|
||||
upgradeDocument(document);
|
||||
const target = locate(document.blocks, selected);
|
||||
if (!target) document.blocks.push(row);
|
||||
else if (canContain(target.block, row)) (target.block.children ??= []).push(row);
|
||||
else target.siblings.splice(target.index + 1, 0, row);
|
||||
return row.id;
|
||||
return place(document, buildRow(columns), parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
const row = buildRow(columns);
|
||||
upgradeDocument(document);
|
||||
// Same placement rule as a single element, so the row does not land somewhere else than the
|
||||
// toolbox promised.
|
||||
const target = insertionTarget(document, selected, 'row');
|
||||
return target ? place(document, row, target.parentId, target.index) : null;
|
||||
}
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
|
||||
+86
-15
@@ -50,10 +50,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.panel-section .eyebrow{margin-bottom:10px}
|
||||
.panel-section h2{font-size:14px;margin-bottom:12px}
|
||||
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
|
||||
.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}
|
||||
.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}
|
||||
.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}
|
||||
.library button span{font-size:19px;line-height:1}
|
||||
.panel-title{display:flex;justify-content:space-between;align-items:center}
|
||||
.panel-title h2{margin-bottom:0}
|
||||
.panel-title+.panel-hint{margin-top:10px}
|
||||
@@ -150,10 +146,33 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
|
||||
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
|
||||
.number-control input[type=number]{font-size:12px;min-height:38px}
|
||||
.side-wrap{margin-bottom:6px}
|
||||
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
|
||||
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
|
||||
.side-wrap{margin-bottom:10px}
|
||||
/* Each field sits on the edge it controls, so the position is the label. */
|
||||
.side-box{display:grid;grid-template-columns:1fr 1fr 1fr;grid-template-rows:auto auto auto;gap:4px;align-items:center;justify-items:center;padding:7px;border:1px dashed var(--line);border-radius:8px;background:#fbfcf8}
|
||||
.side-top{grid-area:1/2}.side-left{grid-area:2/1}.side-right{grid-area:2/3}.side-bottom{grid-area:3/2}
|
||||
.side-box-corners .side-topLeft{grid-area:1/1}.side-box-corners .side-topRight{grid-area:1/3}
|
||||
.side-box-corners .side-bottomLeft{grid-area:3/1}.side-box-corners .side-bottomRight{grid-area:3/3}
|
||||
.side-cell{width:100%}
|
||||
.side-field{width:100%;min-width:0;text-align:center;padding:5px 2px;font-size:11px;min-height:30px}
|
||||
.side-field.is-inherited{color:var(--muted)}
|
||||
.side-link{grid-area:2/2;width:26px;height:26px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px;line-height:1;color:var(--muted);cursor:pointer}
|
||||
.side-link[aria-pressed=true]{border-color:var(--accent);background:#eef5e6;color:var(--accent)}
|
||||
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
||||
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
||||
.side-unit-select{flex:1;min-width:0}
|
||||
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one. */
|
||||
.device-chips{display:flex;gap:2px;margin-left:auto}
|
||||
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--accent)}
|
||||
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
|
||||
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
||||
.design-side-head{margin-bottom:5px}
|
||||
.design-side-head .design-group-label{margin:0}
|
||||
|
||||
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
||||
.scrubbable{cursor:ew-resize}
|
||||
.scrubbable:focus{cursor:text}
|
||||
.design-actions{display:grid;gap:8px;margin-top:22px}
|
||||
.design-actions button{width:100%}
|
||||
|
||||
@@ -188,9 +207,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.editor-page-name h1{font-size:14px}
|
||||
.panel-section h2{font-size:13px;letter-spacing:0}
|
||||
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
|
||||
.library{gap:8px}
|
||||
.library button{min-height:68px;border-radius:9px;font-size:11px;gap:7px}
|
||||
.library button span{font-size:20px}
|
||||
.tree-item{font-size:12px;border-radius:6px}
|
||||
.right-panel label{font-size:12px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
|
||||
@@ -204,8 +220,63 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.canvas-panel{background:#e7ebe5}
|
||||
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
|
||||
.icon-button{border-radius:8px}
|
||||
.row-library{display:grid;gap:6px;margin-bottom:14px}
|
||||
.row-library button{display:flex;align-items:center;gap:10px;min-height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
|
||||
.row-library button:hover:not(:disabled){border-color:var(--accent)}
|
||||
.row-library span{letter-spacing:-2px;font-size:13px}
|
||||
#design-search{font-size:12px;min-height:36px;padding:9px}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The working layer above the preview.
|
||||
|
||||
It sits in the editor document, not in the preview, and carries the same scale transform as
|
||||
the frame - so a rectangle measured inside the frame can be used here without conversion. The
|
||||
layer itself lets the mouse through; only the controls on it catch a click, and each of them
|
||||
is counter-scaled from JavaScript so it stays finger-sized when the preview is shrunk.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.canvas-overlay{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;overflow:hidden;z-index:2}
|
||||
.ov-box{position:absolute;pointer-events:none}
|
||||
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
||||
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
||||
.ov-label{background:#5c6b62;font-weight:600}
|
||||
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
||||
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
||||
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
||||
.ov-tool:hover:not(:disabled){background:#3b4c42}
|
||||
.ov-tool:focus-visible{outline:2px solid #fff;outline-offset:-2px}
|
||||
.ov-tool-danger:hover:not(:disabled){background:#8d2f24}
|
||||
.ov-insert{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:2px solid #fff;border-radius:50%;background:var(--ring,#b55b12);color:#fff;font:700 14px/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 7px #0000003d;pointer-events:auto;opacity:.55;transition:opacity .12s}
|
||||
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
|
||||
#library button[draggable=true]{cursor:grab}
|
||||
#library button[draggable=true]:active{cursor:grabbing}
|
||||
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
|
||||
.insert-hint .text-button{margin-left:4px}
|
||||
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||
|
||||
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
|
||||
fade on the insert points is a transition and is switched off with the rest. */
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The toolbox, grouped the way a page is built.
|
||||
|
||||
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
|
||||
element would land is disabled and carries the reason in its title, so the rule is learned
|
||||
rather than merely enforced.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.library-group + .library-group{margin-top:16px}
|
||||
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
|
||||
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
|
||||
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.library-buttons button{display:flex;align-items:center;gap:7px;text-align:left;min-height:38px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
|
||||
.library-buttons button>span:first-child{flex:none;width:14px;text-align:center;font-size:13px;color:var(--accent)}
|
||||
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
||||
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
|
||||
.library-buttons button:disabled>span:first-child{color:var(--muted)}
|
||||
/* A row is offered by column count, so its glyph shows the count - and needs the room for it,
|
||||
instead of the fixed single-glyph width the other entries share. */
|
||||
.library-buttons button>span.library-row-glyph{width:auto;letter-spacing:0;font-size:10px;color:var(--accent)}
|
||||
.library-buttons button[data-row]{grid-column:span 2}
|
||||
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
|
||||
+213
-27
@@ -1,7 +1,8 @@
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
|
||||
// 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'];
|
||||
@@ -17,6 +18,9 @@ const mayScript = 'true' === root.dataset.canScript;
|
||||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
|
||||
// The preview is drawn at a fixed width and scaled down to fit. The overlay above it needs the
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
@@ -61,8 +65,7 @@ function controls() {
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
}
|
||||
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
updateLibrary();
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
@@ -121,6 +124,7 @@ function highlight(scroll = false) {
|
||||
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||||
const element = selected ? doc.getElementById(selected) : null;
|
||||
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
||||
overlay?.sync();
|
||||
}
|
||||
|
||||
function renderProperties() {
|
||||
@@ -545,9 +549,11 @@ function resizePreview() {
|
||||
const available = Math.max(100, container.clientWidth - padding);
|
||||
const scale = Math.min(1, available / width);
|
||||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||
previewScale = scale;
|
||||
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
|
||||
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
|
||||
frame.title = `Vorschau ${devices[device]} · ${width} px`;
|
||||
overlay?.sync();
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
@@ -606,10 +612,12 @@ async function updatePreview() {
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
doc.addEventListener('submit', event => event.preventDefault());
|
||||
// Every refresh is a new document, so the layer above it has to bind to it again.
|
||||
overlay?.attach();
|
||||
frame.contentWindow.scrollTo(0, top); highlight();
|
||||
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||
$('#preview-status').textContent = 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.';
|
||||
};
|
||||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||||
frame.style.visibility = 'hidden';
|
||||
@@ -650,10 +658,11 @@ async function loadDesignSchema() {
|
||||
designSchema = await response.json();
|
||||
designPanel = createDesignPanel({
|
||||
schema: designSchema,
|
||||
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
|
||||
onChange: options => { controls(); renderTree(); schedulePreview(); if (!options?.keepPanel) renderProperties(); },
|
||||
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
|
||||
getBlock: () => locate(history.document.blocks, selected)?.block,
|
||||
getDevice: () => device,
|
||||
onDevice: setViewport,
|
||||
getSelection: () => selected,
|
||||
getHistory: () => history,
|
||||
});
|
||||
@@ -692,17 +701,110 @@ async function save(publish = false) {
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an element. Without a marked insertion point this behaves as it always did - behind the
|
||||
* selection. With one, set by the plus signs in the preview, it lands exactly there.
|
||||
*/
|
||||
function addElement(type) {
|
||||
if (busy || !history) return;
|
||||
const at = pendingInsert;
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => at
|
||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||
: insertBlock(doc, type, selected));
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
updateLibrary();
|
||||
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
|
||||
$('#insert-hint').hidden = !target;
|
||||
if (target) {
|
||||
if ($('#toolbox').hidden) $('#toggle-library').click();
|
||||
$('#toolbox').scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The toolbox.
|
||||
//
|
||||
// It is built here rather than listed in the template because it has to answer a question that
|
||||
// only the current selection can answer: what fits where the next element would go. A flat list
|
||||
// of every type offered a row on an empty page and a column next to nothing - the structure the
|
||||
// editor describes in its own hint was not the structure the buttons followed.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function buildLibrary() {
|
||||
const library = $('#library');
|
||||
library.replaceChildren();
|
||||
for (const category of CATEGORIES) {
|
||||
const group = document.createElement('div'); group.className = 'library-group';
|
||||
const heading = document.createElement('h4'); heading.className = 'library-group-title'; heading.textContent = category.name;
|
||||
group.append(heading);
|
||||
if (category.hint) {
|
||||
const hint = document.createElement('p'); hint.className = 'library-group-hint'; hint.textContent = category.hint;
|
||||
group.append(hint);
|
||||
}
|
||||
const list = document.createElement('div'); list.className = 'library-buttons';
|
||||
for (const type of category.types) {
|
||||
// A row without columns is an empty box, so it is offered by column count instead - the
|
||||
// shape people actually want when they reach for a row.
|
||||
for (const columns of 'row' === type ? ROW_COLUMNS : [null]) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.draggable = true;
|
||||
button.dataset.add = type;
|
||||
if (columns) button.dataset.row = String(columns);
|
||||
const glyph = document.createElement('span'); glyph.setAttribute('aria-hidden', 'true');
|
||||
glyph.textContent = columns ? '▥'.repeat(Math.min(columns, 4)) : icons[type];
|
||||
if (columns) glyph.className = 'library-row-glyph';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
|
||||
button.append(glyph, text);
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
group.append(list);
|
||||
library.append(list.children.length ? group : document.createComment('leer'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greys out what cannot go where the next element would land, and says where it will land when
|
||||
* it can. The answer comes from the same function the click follows, so the two cannot disagree.
|
||||
*/
|
||||
function updateLibrary() {
|
||||
const ready = Boolean(history);
|
||||
for (const button of $('#library').querySelectorAll('button')) {
|
||||
const type = button.dataset.add;
|
||||
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
|
||||
const target = pendingInsert
|
||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||
: insertionTarget(history.document, selected, type);
|
||||
button.disabled = !target;
|
||||
if (!target) { button.title = whyNot(type); continue; }
|
||||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||||
}
|
||||
}
|
||||
|
||||
$('#library').addEventListener('click', event => {
|
||||
const type = event.target.closest('[data-add]')?.dataset.add;
|
||||
if (!type || busy || !history) return;
|
||||
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
|
||||
});
|
||||
// A row with columns, in one click: the piece people actually look for when they want a row.
|
||||
$('#rows').addEventListener('click', event => {
|
||||
const columns = event.target.closest('[data-row]')?.dataset.row;
|
||||
if (!columns || busy || !history) return;
|
||||
selected = history.change(doc => insertRow(doc, columns, selected)); propertyPanel = 'design'; changed();
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || button.disabled) return;
|
||||
if (button.dataset.row) {
|
||||
if (busy || !history) return;
|
||||
const at = pendingInsert;
|
||||
setPendingInsert(null);
|
||||
const id = history.change(doc => at
|
||||
? insertRowAt(doc, button.dataset.row, at.parentId, at.index)
|
||||
: insertRow(doc, button.dataset.row, selected));
|
||||
if (id) { selected = id; propertyPanel = 'design'; changed(); }
|
||||
return;
|
||||
}
|
||||
addElement(button.dataset.add);
|
||||
});
|
||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
if (!name || busy || !history) return;
|
||||
@@ -729,8 +831,13 @@ document.querySelectorAll('[data-panel]').forEach(button => button.addEventListe
|
||||
$('#structure').addEventListener('dragstart', event => {
|
||||
if (busy) { event.preventDefault(); return; }
|
||||
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
|
||||
if (!id) return;
|
||||
event.dataTransfer.setData('text/plain', id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (block) overlay.startDrag({ kind: 'move', type: block.type, block });
|
||||
});
|
||||
$('#structure').addEventListener('dragend', () => overlay.endDrag());
|
||||
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
|
||||
$('#structure').addEventListener('drop', event => {
|
||||
event.preventDefault(); if (busy) return;
|
||||
@@ -740,17 +847,86 @@ $('#structure').addEventListener('drop', event => {
|
||||
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
|
||||
selected = source; changed();
|
||||
});
|
||||
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
|
||||
$('#delete').addEventListener('click', () => {
|
||||
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
|
||||
});
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
|
||||
});
|
||||
// The actions a block offers. The sidebar buttons and the toolbar in the preview are two ways
|
||||
// into the same functions, so the two can never drift apart.
|
||||
const actions = {
|
||||
duplicate: id => { selected = history.change(doc => duplicateBlock(doc, id)); changed(); },
|
||||
delete: id => {
|
||||
selected = history.change(doc => {
|
||||
const item = locate(doc.blocks, id);
|
||||
if (!item) return null;
|
||||
item.siblings.splice(item.index, 1);
|
||||
return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null;
|
||||
});
|
||||
changed();
|
||||
},
|
||||
move: (id, direction) => {
|
||||
history.change(doc => {
|
||||
const item = locate(doc.blocks, id);
|
||||
const next = item.index + direction;
|
||||
if (next < 0 || next >= item.siblings.length) return;
|
||||
[item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]];
|
||||
});
|
||||
selected = id; changed();
|
||||
},
|
||||
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
|
||||
};
|
||||
|
||||
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
|
||||
$('#delete').addEventListener('click', () => actions.delete(selected));
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
});
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
overlay = createCanvasOverlay({
|
||||
stage: $('#preview-stage'),
|
||||
frame: $('#preview'),
|
||||
getScale: () => previewScale,
|
||||
getDocument: () => history.document,
|
||||
getSelected: () => selected,
|
||||
onAction: (action, id) => {
|
||||
if (!id || busy || !history) return;
|
||||
if (action === 'up') actions.move(id, -1);
|
||||
else if (action === 'down') actions.move(id, 1);
|
||||
else actions[action]?.(id);
|
||||
},
|
||||
onInsert: (id, where) => {
|
||||
const item = locate(history.document.blocks, id);
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
return;
|
||||
}
|
||||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||||
if (moved) { selected = moved; changed(); }
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are dragged out of the toolbox straight onto the page. The click route stays, so the
|
||||
// toolbox remains usable with the keyboard alone.
|
||||
$('#library').addEventListener('dragstart', event => {
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || button.disabled || busy || !history) { event.preventDefault(); return; }
|
||||
const type = button.dataset.add;
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('text/plain', type);
|
||||
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
|
||||
// one does not exist until it lands.
|
||||
overlay.startDrag({ kind: 'add', type, columns: button.dataset.row ?? null, block: { type } });
|
||||
});
|
||||
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
||||
|
||||
$('#select-page').addEventListener('click', () => choose(null));
|
||||
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||||
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||||
@@ -787,11 +963,20 @@ $('#download').addEventListener('click', () => {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
$('#preview').className = `viewport-${button.dataset.viewport}`;
|
||||
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
|
||||
}));
|
||||
/**
|
||||
* Switches the preview size and the device the design panel edits. Both move together on
|
||||
* purpose: editing the mobile value while the preview shows desktop means changing something
|
||||
* you cannot see.
|
||||
*/
|
||||
function setViewport(name) {
|
||||
if (!devices[name]) return;
|
||||
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.viewport === name)));
|
||||
$('#preview').className = `viewport-${name}`;
|
||||
device = name;
|
||||
if (history) history.mergeKey = null;
|
||||
resizePreview(); renderProperties(); controls();
|
||||
}
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
||||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||||
@@ -802,4 +987,5 @@ window.addEventListener('keydown', event => {
|
||||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||
});
|
||||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||
buildLibrary();
|
||||
load();
|
||||
+159
-3
@@ -64,7 +64,7 @@ try {
|
||||
console.log('Preview status:', await page.locator('#preview-status').innerText());
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('h1').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
|
||||
@@ -76,6 +76,95 @@ try {
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
|
||||
|
||||
// --- Die Vorschau als Arbeitsflaeche -------------------------------------------------------
|
||||
// Rahmen, Werkzeugleiste am Element, Einfuegepunkte und Ziehen aus dem Werkzeugkasten.
|
||||
const inPreview = selector => page.evaluate(
|
||||
sel => document.querySelector('#preview').contentDocument?.querySelector(sel)?.id ?? null, selector);
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('.ov-select').waitFor({ state: 'visible' });
|
||||
assert.equal(await page.locator('.ov-toolbar-name').innerText(), 'Überschrift');
|
||||
await page.getByRole('toolbar', { name: 'Aktionen für das gewählte Element' }).waitFor();
|
||||
// Die Leiste benennt das Element, auf das sie wirkt - sonst haette sie denselben Namen wie
|
||||
// die gleichnamige Schaltflaeche in der Seitenleiste.
|
||||
await page.getByRole('button', { name: 'Überschrift duplizieren', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Element über Überschrift auswählen', exact: true }).click();
|
||||
assert.notEqual(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
|
||||
// Einfuegepunkt: das naechste Element landet genau dort und nicht am Ende.
|
||||
await frame.locator('h1').click();
|
||||
const headingId = await inPreview('h1');
|
||||
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
|
||||
await page.locator('#insert-hint').waitFor({ state: 'visible' });
|
||||
await page.locator('#library button[data-add="text"]').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
|
||||
assert.equal(await page.locator('#insert-hint').isVisible(), false);
|
||||
await page.waitForFunction(id => {
|
||||
const next = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.nextElementSibling;
|
||||
return Boolean(next?.classList.contains('block-text'));
|
||||
}, headingId);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
|
||||
// Ziehen aus dem Werkzeugkasten auf die Seite.
|
||||
await frame.locator('h1').waitFor();
|
||||
const beforeButtons = await page.locator('#structure [data-block-id]').count();
|
||||
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
|
||||
// Oben abgelegt heisst davor, nicht dahinter.
|
||||
await page.waitForFunction(id => {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
|
||||
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
|
||||
// Eine Zeile kommt nicht aus dem Nichts, und eine Spalte gehoert in eine Zeile.
|
||||
const toolboxState = type => page.evaluate(selector => {
|
||||
const button = document.querySelector(selector);
|
||||
return button ? { disabled: button.disabled, title: button.title } : null;
|
||||
}, `#library button[data-add="${type}"]:not([data-row])`);
|
||||
const rowState = () => page.evaluate(() => {
|
||||
const button = document.querySelector('#library button[data-row="2"]');
|
||||
return { disabled: button.disabled, title: button.title };
|
||||
});
|
||||
|
||||
await page.locator('#select-page').click();
|
||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte ohne Zeile gesperrt');
|
||||
assert.match((await toolboxState('column')).title, /gehört in Zeile/);
|
||||
assert.equal((await rowState()).disabled, true, 'Zeile ohne Sektion gesperrt');
|
||||
assert.match((await rowState()).title, /gehört in eine Sektion/);
|
||||
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
|
||||
|
||||
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
|
||||
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||
// einem anderen Dokument.
|
||||
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
|
||||
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
|
||||
|
||||
// Und in der Zeile wird die Spalte moeglich.
|
||||
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
||||
await page.locator('#library button[data-row="3"]').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
|
||||
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
|
||||
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
|
||||
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
|
||||
// Differenz.
|
||||
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
checks.push({ name: 'the toolbox follows the structure', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/toolbox.png' });
|
||||
console.log('Context aware toolbox passed.');
|
||||
|
||||
// Zurueck in den Zustand, den dieser Abschnitt vorgefunden hat: die folgenden Pruefungen
|
||||
// setzen eine bestehende Auswahl voraus.
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).click();
|
||||
await page.locator('#duplicate:not([disabled])').waitFor();
|
||||
console.log('Canvas overlay, insert points and drag and drop passed.');
|
||||
|
||||
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
|
||||
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
|
||||
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
|
||||
@@ -109,7 +198,7 @@ try {
|
||||
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
|
||||
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
|
||||
await page.click('#undo');
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
checks.push({ name: 'preview never shows unstyled content', passed: true });
|
||||
console.log('Preview edits and history passed.');
|
||||
|
||||
@@ -184,6 +273,73 @@ try {
|
||||
await setLength('style-padding-top', 32);
|
||||
await frameValue('#hero', 'paddingTop', '32px');
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
|
||||
// --- Die Optionen selbst ------------------------------------------------------------------
|
||||
// Geraetechips je Option, Abstandsrahmen mit Kette, Ziehfeld.
|
||||
|
||||
// Der Chip zeigt, wo ein abweichender Wert liegt: Innenabstand hat oben gerade Desktop und
|
||||
// Mobil bekommen, Tablet nicht.
|
||||
const chip = device => page.locator(`#style-padding-unit`).locator('xpath=ancestor::div[contains(@class,"side-wrap")]/preceding-sibling::div[contains(@class,"design-side-head")][1]')
|
||||
.locator(`.device-chip[data-device="${device}"]`);
|
||||
await chip('desktop').waitFor();
|
||||
assert.equal(await chip('desktop').evaluate(el => el.classList.contains('device-chip-own')), true, 'Desktop hat einen eigenen Innenabstand');
|
||||
assert.equal(await chip('mobile').evaluate(el => el.classList.contains('device-chip-own')), true, 'Mobil hat einen eigenen Innenabstand');
|
||||
assert.equal(await chip('tablet').evaluate(el => el.classList.contains('device-chip-own')), false, 'Tablet erbt');
|
||||
assert.equal(await chip('desktop').getAttribute('aria-pressed'), 'true', 'Desktop ist die bearbeitete Groesse');
|
||||
|
||||
// Ein Klick auf den Chip wechselt Bedienfeld und Vorschau zusammen.
|
||||
await chip('mobile').click();
|
||||
assert.equal(await page.locator('[data-viewport="mobile"]').getAttribute('aria-pressed'), 'true');
|
||||
await chip('desktop').click();
|
||||
assert.equal(await page.locator('[data-viewport="desktop"]').getAttribute('aria-pressed'), 'true');
|
||||
|
||||
// Der Rahmen aendert ohne Kette nur die eine Seite.
|
||||
await setLength('style-padding-left', 24);
|
||||
await frameValue('#hero', 'paddingLeft', '24px');
|
||||
await frameValue('#hero', 'paddingRight', '0px');
|
||||
// Mit Kette werden alle vier gleichgezogen.
|
||||
await page.locator('.side-link').first().click();
|
||||
assert.equal(await page.locator('.side-link').first().getAttribute('aria-pressed'), 'true');
|
||||
await setLength('style-padding-top', 40);
|
||||
for (const seite of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']) await frameValue('#hero', seite, '40px');
|
||||
await page.locator('.side-link').first().click();
|
||||
|
||||
// Die gemeinsame Einheit schreibt die gesetzten Seiten um, ohne Zahlen zu verlieren.
|
||||
await page.locator('#style-padding-unit').selectOption('rem');
|
||||
await frameValue('#hero', 'paddingTop', '640px');
|
||||
|
||||
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
|
||||
await page.locator('#structure [data-block-id="headline"]').click();
|
||||
await openGroup('Typografie');
|
||||
const scrub = page.locator('#style-fontSize');
|
||||
await scrub.scrollIntoViewIfNeeded();
|
||||
const box = await scrub.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
const gezogen = Number(await scrub.inputValue());
|
||||
assert.equal(gezogen, 92, `Ziehen um 20 px erhoeht 72 auf 92, gemessen: ${gezogen}`);
|
||||
await frameValue('#headline', 'fontSize', '92px');
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await frameValue('#headline', 'fontSize', '72px');
|
||||
checks.push({ name: 'device chips, spacing box and scrub fields', passed: true });
|
||||
|
||||
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
|
||||
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
|
||||
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
|
||||
await page.locator('#structure [data-block-id="hero"]').click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
await page.locator('#style-padding-unit').selectOption('px');
|
||||
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
|
||||
await setLength('style-padding-top', 96);
|
||||
await frameValue('#hero', 'paddingTop', '96px');
|
||||
await frameValue('#hero', 'paddingLeft', '0px');
|
||||
await openGroup('Abstand');
|
||||
await page.locator('#style-padding-top').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: 'reports/editor/options.png' });
|
||||
console.log('Per option device chips, spacing box and scrub fields passed.');
|
||||
|
||||
// The background group is collapsed by default, and the readable field next to the swatch is
|
||||
// what a person types into.
|
||||
await openGroup('Hintergrund');
|
||||
@@ -473,7 +629,7 @@ try {
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await page.locator('#structure [data-block-id="headline"]').click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await axe('desktop editor');
|
||||
await page.screenshot({ path: 'reports/editor/editor-desktop.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
|
||||
@@ -38,6 +38,9 @@ final class ArchiveUpdate
|
||||
*/
|
||||
private const MARKE = 'var/update/offen.json';
|
||||
|
||||
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
|
||||
private const PROTOKOLL = 'var/updates.log';
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private Updater $updater,
|
||||
@@ -96,11 +99,19 @@ final class ArchiveUpdate
|
||||
public function run(?string $version = null): array
|
||||
{
|
||||
$schritte = [];
|
||||
$melden = static function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
|
||||
//
|
||||
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
|
||||
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
|
||||
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
|
||||
// Einzige, was von einem gescheiterten Update übrig bleibt.
|
||||
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
|
||||
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
|
||||
|
||||
return $ok;
|
||||
};
|
||||
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
|
||||
$neueste = $this->latest();
|
||||
if (null === $neueste) {
|
||||
@@ -259,6 +270,7 @@ final class ArchiveUpdate
|
||||
public function finish(Installation $installation): array
|
||||
{
|
||||
$schritte = [];
|
||||
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
try {
|
||||
$namen = $installation->migrate();
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
@@ -266,12 +278,14 @@ final class ArchiveUpdate
|
||||
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||
} catch (\Throwable $fehler) {
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||
$this->updater->version(),
|
||||
@@ -280,6 +294,17 @@ final class ArchiveUpdate
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile ins Protokoll, und zwar sofort.
|
||||
*
|
||||
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
|
||||
* schreibbar ist, wäre absurd.
|
||||
*/
|
||||
private function protokoll(string $zeile): void
|
||||
{
|
||||
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
|
||||
private function mergePublic(string $von, string $nach): void
|
||||
{
|
||||
|
||||
@@ -19,16 +19,9 @@
|
||||
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
|
||||
<div id="rows" class="row-library">
|
||||
{% for count, label in {2: 'Zeile, 2 Spalten', 3: 'Zeile, 3 Spalten', 4: 'Zeile, 4 Spalten'} %}
|
||||
<button type="button" data-row="{{ count }}" disabled><span aria-hidden="true">{{ {2: '▥▥', 3: '▥▥▥', 4: '▥▥▥▥'}[count] }}</span> {{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',postnav:'⇄',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',legal:'§',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<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>
|
||||
<div id="library" class="library"></div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
<div id="presets" class="preset-library"><button data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
||||
|
||||
@@ -169,7 +169,12 @@
|
||||
{% for step in steps %}
|
||||
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
|
||||
<span><strong>{{ step.step }}</strong> {{ step.detail }}</span>
|
||||
{# `step.label` ist die Schreibweise bis 1.4.4. Sie muss hier stehen bleiben: Beim
|
||||
Update von 1.4.4 auf 1.4.5 schreibt der **alte** Code die Schritte in die
|
||||
Sitzung und der **neue** zeigt sie an - eine Fassung, die nur die neue
|
||||
Schreibweise kennt, könnte das Update, das sie selbst einspielt, nicht
|
||||
berichten. #}
|
||||
<span><strong>{{ step.step|default(step.label|default('Schritt')) }}</strong> {{ step.detail }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
@@ -219,6 +219,22 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertStringContainsString('herunterladen', $gescheitert['detail']);
|
||||
}
|
||||
|
||||
public function testEveryStepIsWrittenToTheLogWhileItHappens(): void
|
||||
{
|
||||
// Auf Umuts Hosting endete die Ergebnisseite mit 500, und damit war auch verloren, was
|
||||
// der Updater berichtet hatte. Eine Zeile in einer Datei überlebt das - und auf einem
|
||||
// Hosting ohne Konsole ist sie manchmal das Einzige, was von einem gescheiterten Update
|
||||
// übrig bleibt. Geschrieben wird während des Laufs, nicht am Ende: Ein Lauf, der
|
||||
// mittendrin abbricht, hat sonst nichts hinterlassen.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
|
||||
$this->updater()->run();
|
||||
|
||||
$protokoll = (string) file_get_contents($this->root.'/var/updates.log');
|
||||
self::assertStringContainsString('Update aus dem Archiv', $protokoll);
|
||||
self::assertStringContainsString('ok Fassung ermitteln', $protokoll);
|
||||
self::assertStringContainsString('FEHLER Prüfsumme', $protokoll, 'Gerade der gescheiterte Schritt muss dort stehen.');
|
||||
}
|
||||
|
||||
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
|
||||
{
|
||||
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
|
||||
|
||||
+129
-1
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, 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 { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
@@ -364,3 +364,131 @@ test('a current document still accepts new blocks, and every block type can be i
|
||||
assert.throws(() => upgradeDocument({ schemaVersion: 99, blocks: [] }, 6), /Unbekanntes Dokumentschema/);
|
||||
assert.throws(() => upgradeDocument({ schemaVersion: 5, blocks: [] }, SCHEMA_VERSION + 1), /Unbekanntes Dokumentschema/);
|
||||
});
|
||||
|
||||
// --- Punktgenaues Einfuegen und Verschieben -------------------------------------------------
|
||||
// Die Grundlage fuer das Ablegen in der Vorschau: dort zaehlt die Stelle, nicht die Auswahl.
|
||||
|
||||
test('a drop lands at the exact position, also at the very end of a list', () => {
|
||||
const document = fixture();
|
||||
const section = locate(document.blocks, 's1').block;
|
||||
insertBlockAt(document, 'text', 's1', 0);
|
||||
assert.deepEqual(section.children.map(block => block.type), ['text', 'heading', 'text']);
|
||||
insertBlockAt(document, 'button', 's1', section.children.length);
|
||||
assert.equal(section.children.at(-1).type, 'button');
|
||||
// Ein Index jenseits des Endes haengt an, statt zu scheitern.
|
||||
assert.ok(insertBlockAt(document, 'button', 's1', 99));
|
||||
assert.equal(section.children.length, 5);
|
||||
});
|
||||
|
||||
test('moving to an exact position settles the shift before it clamps', () => {
|
||||
const order = document => document.blocks[0].children.map(block => block.id);
|
||||
const document = fixture();
|
||||
insertBlockAt(document, 'button', 's1', 2);
|
||||
const ids = order(document);
|
||||
// Nach hinten: die Entnahme verschiebt jede spaetere Stelle um eins.
|
||||
assert.equal(moveBlockTo(document, ids[0], 's1', 2), true);
|
||||
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
|
||||
// Ganz ans Ende. Ohne die richtige Reihenfolge von Verrechnen und Begrenzen landete der
|
||||
// Block hier eine Stelle zu frueh.
|
||||
assert.equal(moveBlockTo(document, ids[1], 's1', 3), true);
|
||||
assert.deepEqual(order(document), [ids[0], ids[2], ids[1]]);
|
||||
// Nach vorne: dort wird nichts verrechnet.
|
||||
assert.equal(moveBlockTo(document, ids[1], 's1', 0), true);
|
||||
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
|
||||
});
|
||||
|
||||
test('a drop never detaches a branch and never breaks a container rule', () => {
|
||||
const document = fixture();
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
// Eine Sektion in ihr eigenes Kind: das wuerde den Ast abtrennen.
|
||||
assert.equal(moveBlockTo(document, 's1', 'h1', 0), false);
|
||||
assert.equal(moveBlockTo(document, 's1', 's1', 0), false);
|
||||
assert.equal(document.blocks.length, 2);
|
||||
// Eine Galerie nimmt nur Bilder.
|
||||
const galleryId = insertBlock(document, 'gallery', null);
|
||||
assert.equal(moveBlockTo(document, 'h1', galleryId, 0), false);
|
||||
assert.equal(locate(document.blocks, 'h1').parent.id, 's1');
|
||||
assert.equal(insertBlockAt(document, 'text', galleryId, 0), null);
|
||||
assert.ok(insertBlockAt(document, 'image', galleryId, 0));
|
||||
});
|
||||
|
||||
test('the top level takes everything the validator takes there, and nothing else', () => {
|
||||
const document = fixture();
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
// Ein Akkordeon-Eintrag und ein Tab gehoeren in ihre Gruppe - der Validator lehnt sie sonst ab.
|
||||
assert.equal(dropList(document, null, { type: 'accordionitem' }), null);
|
||||
assert.equal(dropList(document, null, { type: 'tabitem' }), null);
|
||||
// Alles andere darf dort stehen, genau wie ueber die Schaltflaechen.
|
||||
for (const type of ['section', 'heading', 'image', 'text']) {
|
||||
assert.equal(dropList(document, null, { type }), document.blocks, type);
|
||||
}
|
||||
// Beim Ziehen aus dem Werkzeugkasten gibt es den Block noch nicht.
|
||||
assert.doesNotThrow(() => dropList(document, 's1', null));
|
||||
});
|
||||
|
||||
// --- Sektion, Zeile, Spalte, Inhalt ----------------------------------------------------------
|
||||
// Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, gilt jetzt auch fuer
|
||||
// die Schaltflaechen: eine Zeile nahm bisher jedes Kind, eine Spalte stand ueberall.
|
||||
|
||||
test('a row takes columns and nothing else, a column only lives in a row', () => {
|
||||
const document = fixture();
|
||||
const rowId = insertRow(document, 3, 's2');
|
||||
const row = locate(document.blocks, rowId).block;
|
||||
assert.equal(row.children.length, 3);
|
||||
assert.ok(row.children.every(child => child.type === 'column'));
|
||||
// Eine Ueberschrift gehoert nicht neben die Spalten, sondern in eine davon.
|
||||
assert.equal(insertBlockAt(document, 'heading', rowId, 0), null);
|
||||
assert.equal(row.children.length, 3);
|
||||
assert.ok(insertBlockAt(document, 'heading', row.children[0].id, 0));
|
||||
// Eine Spalte gehoert nicht in eine Sektion.
|
||||
assert.equal(insertBlockAt(document, 'column', 's2', 0), null);
|
||||
assert.ok(insertBlockAt(document, 'column', rowId, 3));
|
||||
});
|
||||
|
||||
test('nothing that needs a container is offered at the top of a document', () => {
|
||||
const document = fixture();
|
||||
for (const type of ['row', 'column', 'accordionitem', 'tabitem']) {
|
||||
assert.equal(insertionTarget(document, null, type), null, type);
|
||||
assert.equal(insertBlock(document, type, null), null, type);
|
||||
}
|
||||
// Eine Sektion dagegen schon - damit faengt eine Seite an.
|
||||
assert.ok(insertBlock(document, 'section', null));
|
||||
});
|
||||
|
||||
test('the toolbox is told the same place the click will use', () => {
|
||||
const document = fixture();
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
const rowId = insertRow(document, 2, 's2');
|
||||
const columnId = locate(document.blocks, rowId).block.children[0].id;
|
||||
// In einer Zeile ist nur eine Spalte moeglich, und sie landet in der Zeile.
|
||||
assert.deepEqual(insertionTarget(document, rowId, 'column'), { parentId: rowId, index: 2 });
|
||||
// Eine Ueberschrift nimmt die Zeile nicht - sie steigt auf und landet hinter der Zeile in der
|
||||
// Sektion. Sperren waere unnoetig: die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
|
||||
assert.deepEqual(insertionTarget(document, rowId, 'heading'), { parentId: 's2', index: 1 });
|
||||
// In einer Spalte ist Inhalt moeglich, und er landet in der Spalte.
|
||||
assert.equal(insertionTarget(document, columnId, 'heading').parentId, columnId);
|
||||
// Eine weitere Spalte neben der gewaehlten: eine Ebene hoeher, direkt dahinter.
|
||||
assert.deepEqual(insertionTarget(document, columnId, 'column'), { parentId: rowId, index: 1 });
|
||||
// Und die Auskunft stimmt mit der Tat ueberein.
|
||||
for (const [selection, type] of [[rowId, 'column'], [columnId, 'heading'], [columnId, 'column'], ['s2', 'row']]) {
|
||||
const promised = insertionTarget(document, selection, type);
|
||||
const id = insertBlock(document, type, selection);
|
||||
assert.equal(locate(document.blocks, id).parent?.id ?? null, promised.parentId, `${type} aus ${selection}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every block type appears in exactly one toolbox group', () => {
|
||||
const listed = CATEGORIES.flatMap(category => category.types);
|
||||
assert.equal(listed.length, new Set(listed).size, 'kein Typ doppelt');
|
||||
assert.deepEqual([...listed].sort(), Object.keys(blockLabels).sort());
|
||||
});
|
||||
|
||||
test('a row dropped at an exact position brings its columns along', () => {
|
||||
const document = fixture();
|
||||
assert.ok(insertRowAt(document, 4, 's1', 0));
|
||||
const row = locate(document.blocks, 's1').block.children[0];
|
||||
assert.equal(row.type, 'row');
|
||||
assert.equal(row.children.length, 4);
|
||||
// Und in eine Zeile hinein gibt es keine zweite Zeile.
|
||||
assert.equal(insertRowAt(document, 2, row.id, 0), null);
|
||||
});
|
||||
Reference in new issue
Block a user