Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a044e59e9 | ||
|
|
149e1fe520 | ||
|
|
b783d9d1e5 |
No files matched your search
@@ -42,7 +42,17 @@ Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern u
|
||||
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
|
||||
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
|
||||
### Platz und Blockgestaltung im Editor (1.6.1)
|
||||
|
||||
**Leere Layouts ab 1.6.2:** Sektionen und Spalten zeigen eine umrahmte Einfügefläche. In einer leeren Sektion direkt **1–4 Spalten** wählen; in einer Spalte **Inhalt hinzufügen** drücken und links das gewünschte Element auswählen. Alternativ ein Element auf die Fläche ziehen: die Markierung nennt das genaue Ziel, am Vorschaurand wird mitgescrollt. Neue Container werden in den sichtbaren Ausschnitt gebracht. Diese Hilfen erscheinen nur im Editor, verschwinden nach dem Befüllen und kehren bei Undo zurück.
|
||||
|
||||
**Neue Sektion auf Seitenebene** steht dauerhaft oben links und fügt nach dem aktuellen Seitenabschnitt ein, unabhängig von der ausgewählten Verschachtelung oder einem gesetzten Plus-Einfügepunkt. Auch „Sektion“ im Werkzeugkasten verwendet ohne expliziten Einfügepunkt die Seitenebene. Verschachtelte Sektionen sind weiterhin über die Plus-Einfügepunkte oder Drag-and-drop möglich.
|
||||
|
||||
Die Trennlinien links und rechts der Vorschau lassen sich ziehen. Mit Tab fokussieren und mit den Pfeiltasten ändern; Umschalt vergrößert den Schritt, Home/End wählen die Grenzen, Doppelklick setzt die jeweilige Breite zurück. Die Breiten werden im Browser behalten und bei schmalen Fenstern an den verfügbaren Platz angepasst. Auf dem Telefon stehen die Panels unter der Vorschau.
|
||||
|
||||
Text und Überschriften nutzen die Containerbreite. Die Sektion wählt unter **Inhalt → Inhaltsbreite** zwischen Websitebreite und randloser Ausgabe. **Design → Größe → Maximale Breite** begrenzt ein einzelnes Element ausdrücklich, etwa auf `60ch`; „Ohne Begrenzung“ hebt einen eigenen Grenzwert auf. Der Feinschliff zeigt passende Optionen für Text, Bild oder Container zuerst. **Alle Eigenschaften anzeigen** macht die übrigen Optionen zugänglich. Bereits gespeicherte Werte bleiben sichtbar.
|
||||
|
||||
## Produktionsmodus lokal prüfen
|
||||
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
|
||||
|
||||
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in `docs/FLATSOME-ABGLEICH.md`).
|
||||
|
||||
`preview-containers.js` ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
|
||||
|
||||
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. `preview.css` und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
|
||||
|
||||
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
|
||||
|
||||
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes `toggle`-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
|
||||
|
||||
## ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
|
||||
|
||||
Textblöcke sowie H1/H2 hatten im Basis-CSS feste Höchstbreiten von 690/900/760 px. Sie folgen jetzt der Breite ihres Containers; ausdrücklich gespeicherte `maxWidth`-Werte bleiben wirksam. Sektionsbreiten und Theme-Container bleiben bewusste Layoutentscheidungen. Das verändert gegebenenfalls den Zeilenumbruch bestehender Seiten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Quelle. Kein Dokument wird dafür umgeschrieben, keine Schema- oder Datenbankmigration ist erforderlich. Rückweg: vorheriges Basis-CSS oder eine explizite maximale Breite am betreffenden Block.
|
||||
|
||||
Ein Klick auf „Sektion“ erzeugt eine neue Sektion nach dem obersten Block der aktuellen Auswahl. Die dauerhaft sichtbare Aktion „Neue Sektion auf Seitenebene“ verwirft zuvor gesetzte Einfügepunkte. Bewusst gesetzte Plus-Einfügepunkte und Drag-and-drop dürfen weiterhin verschachteln. Die Einfügeauskunft und die Modellaktion verwenden denselben Zielermittler; Änderungen bleiben einzelne Undo-Schritte.
|
||||
|
||||
Die Seitenleisten sind unabhängig zwischen 240/280 und 560 px verstellbar, soweit wenigstens 280 px für die Vorschau bleiben. Ziehen, Pfeiltasten, Home/End und Doppelklick sind unterstützt. Präferenzen liegen lokal im Browser, außerhalb von Inhalt, Revisionen und Undo. Schmalere Fenster reduzieren die sichtbaren Breiten vorübergehend; bis 850 px folgen die Panels der Vorschau, bis 560 px stehen sie untereinander. Die Vorschau hat keinen zusätzlichen Außenabstand; Desktop wächst oberhalb von 1240 px mit dem verfügbaren Platz, Tablet/Mobil behalten ihre definierten Layoutbreiten.
|
||||
|
||||
Der Feinschliff filtert nach Blocktyp und ordnet passende Gruppen zuerst: Bildausschnitt bei Bildern, Typografie bei Text, Layout bei Containern. „Alle Eigenschaften anzeigen“ öffnet den vollständigen Vertrag. Bereits gespeicherte Eigenschaften bleiben auch außerhalb des üblichen Blockprofils auffindbar; Validierung und gespeichertes Schema ändern sich nicht. Beim Auswahlwechsel beginnt das Panel oben. `auto` beziehungsweise `none` sind für die passenden Größenfelder explizit auswählbar. VERSION 1.6.1 erneuert die Cachekennung aller Editor-Module.
|
||||
|
||||
## ADR 001 – PHP-Webhosting, Symfony 7.4
|
||||
|
||||
Umut hat im laufenden Auftrag ausdrücklich herkömmliches PHP-Webhosting gewählt. Deshalb PHP statt des zunächst aus dem Ordnernamen vermuteten Django-Stacks. Symfony 7.4 LTS benötigt PHP ab 8.2 und bringt Routing, Dependency Injection, Konfiguration und später anschließbare Sicherheitskomponenten mit. Twig maskiert Inhalte standardmäßig. Doctrine DBAL und Migrations übernehmen Datenbankzugriff und Migrationen. Keine eigene Authentifizierung erfinden: vor HTTP-Schreibzugriffen Symfony Security, CSRF-Schutz und serverseitige Rollen ergänzen.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Abgleich mit dem Flatsome UX Builder
|
||||
|
||||
## Bedienkorrektur für leere Layouts (14.09.2026, KantSites 1.6.2)
|
||||
|
||||
Erneuter Quelltextabgleich mit dem von Umut genannten Ordner `C:\Users\UmutKantarci\Downloads\flatsome`, Theme-Version **3.20.9** laut `style.css`. Keine laufende WordPress-Installation dieses Pakets gestartet; die folgenden Aussagen stammen aus seinen tatsächlichen Builder-Dateien.
|
||||
|
||||
| Beobachtung im Flatsome-Code | Umsetzung in KantSites |
|
||||
| --- | --- |
|
||||
| `assets/css/builder/core/content.css`: leeres `content` mit mindestens 90 px Höhe, getöntem Hintergrund und gepunktetem Rahmen | Vorschau-only-Flächen für leere Sektionen, Zeilen und Spalten mit realer Mindesthöhe, Rahmen und Typbezeichnung |
|
||||
| `tools-addable.is-empty` und `assets/js/builder/core/content.js`: Plus in der Mitte, eigener Einfüge-/Drop-Zustand für leere Container | „Inhalt hinzufügen“ im Zielbereich; Spalten werden nummeriert; der Einfügepunkt wird konkret markiert und benannt |
|
||||
| `inc/builder/shortcodes/row.php`: fertige Zeilen mit einer, zwei, drei und vier Spalten | Diese vier gleichmäßigen Layouts direkt in der leeren Sektion anbieten, einschließlich passender mobiler Stapelung |
|
||||
|
||||
Zusätzlich: neue Container werden nach dem Rendern in den sichtbaren Ausschnitt gebracht, ein Auswahlwechsel löst alte Einfügepunkte, und beim Ziehen scrollt die Vorschau an den Rändern mit. Die Ablageanzeige nennt den Zielcontainer; beim Verschieben von Spalten entscheidet die horizontale Position. Editorhilfen entstehen ausschließlich im Vorschau-DOM, nicht im gespeicherten Dokument oder in der öffentlichen Ausgabe.
|
||||
|
||||
Der ältere Gesamtabgleich unten bleibt eine historische Bestandsaufnahme. Diese Änderung übernimmt konkrete Bedienprinzipien für den Aufbau, keine vollständige Flatsome-Oberfläche und keine WordPress-/Shortcode-Abhängigkeit.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
|
||||
|
||||
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
|
||||
|
||||
Vergleich mit lokalen Flatsome-3.20.9-Quelldateien, keine laufende WordPress-Referenz geprüft. Kein neuer Lighthouse-Lauf, da der öffentliche Renderer und dessen CSS unverändert sind; die Messung von 1.6.1 ist weiterhin als solche zu lesen. Kein neuer Screenreader-, Firefox-, Safari-, Touchgeräte- oder Felddatennachweis.
|
||||
|
||||
## Ergänzung: Flexible Editorfläche und Inhaltsbreiten, 14.09.2026 (1.6.1)
|
||||
|
||||
`npm.cmd test`: 30 Modelltests; PHP 8.4.22: 452 Tests / 3476 Assertions. Der vollständige isolierte Playwright-Lauf einschließlich axe, Speichern/Neuladen, Veröffentlichung, Konfliktbehandlung und neuer Panel-/Sektionsprüfungen besteht. Bildschirmbreiten 390/850/1100/1920 px ohne horizontalen Überlauf geprüft. Screenshotprüfung für Desktop, Bildinspektor und Telefon; kein neuer Screenreader-Test.
|
||||
|
||||
`PHP_BINARY=C:\php84\php.exe`, `npm.cmd run test:editor -- --audit`: Lighthouse **13.4.1**, lokaler Symfony-Produktionsmodus mit isolierter Datenbank und den vom Editortest erstellten Inhalten/Bildern. Je **drei** Läufe im Mobil- und Desktopprofil aus `scripts/audit.mjs`. Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing in jedem Lauf und damit in beiden Medianen **100**. Vollständige Geräte-/Drosselungs- und Umgebungseinstellungen sowie Ergebnisse stehen in `reports/lighthouse-editor/summary.json` und den sechs Einzelberichten; funktionale Ergebnisse in `reports/editor/result.json`.
|
||||
|
||||
Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der festen Text-/Überschriftenbreiten. Sie ist keine Abnahme aller Pilot- oder Kundenseiten. Felddaten zu LCP/INP/CLS wurden nicht erhoben; INP wird aus diesem Ladelauf nicht abgeleitet. GEO/Auffindbarkeit ist durch den Agentic-Browsing-Score nicht nachgewiesen.
|
||||
|
||||
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
|
||||
|
||||
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026)
|
||||
|
||||
**Erledigt:** Leere Sektionen, Zeilen und Spalten besitzen in der Vorschau sichtbare, beschriftete Arbeitsflächen. „Inhalt hinzufügen“ setzt einen genauen Einfügepunkt, öffnet den Werkzeugkasten und fokussiert die Suche. Leere Sektionen bieten unmittelbar 1–4 Spalten; neue Elemente werden sichtbar gescrollt. Beim Ziehen sind Container und Ablageziel erkennbar, die Vorschau scrollt am Rand mit. Spalten werden auf Desktop horizontal, bei mobiler Stapelung vertikal eingeordnet. Geöffnete Feinschliff-Gruppen bleiben auch bei schnellen Änderungen erhalten. Cachekennung 1.6.2; keine Schema- oder Datenbankmigration. Die Hilfen werden ausschließlich im Editor-DOM erzeugt und erscheinen weder in gespeicherten Dokumenten noch auf öffentlichen Seiten. Entscheidungen: ADR 075.
|
||||
|
||||
**Referenz:** Die tatsächlichen Builder-Dateien des bereitgestellten Flatsome 3.20.9 wurden erneut gelesen. Übernommen sind sichtbare Leerflächen, direkte Plus-Aktionen und schnelle Spaltenlayouts. Konkrete Fundstellen und Umfang stehen in `docs/FLATSOME-ABGLEICH.md`; keine laufende WordPress-Referenzinstallation geprüft.
|
||||
|
||||
**Geprüft:** `npm.cmd test` 30/30; PHP 8.4.22 `vendor/bin/phpunit --filter EditorSecurityTest` 27/27 mit 312 Assertions; JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig. Der vollständige isolierte `npm.cmd run test:editor` besteht einschließlich axe-Prüfungen. Neue Abdeckung: sichtbare leere Sektion, Tastaturwahl des Zweispaltenlayouts, Einfügen in genau die zweite Spalte, Ziehen in die erste Spalte mit Zielmarkierung und automatischem Scrollen, Undo/Redo, leere Layouts nach Speichern/Neuladen, Spaltenumsortierung Desktop/Mobil und Veröffentlichung ohne Editorhilfen. Desktop-, Mobil- und Drag-Ansichten anhand der Screenshots visuell geprüft. Bericht: `reports/editor/result.json`; Bilder: `empty-section.png`, `empty-layout-desktop.png`, `empty-layout-mobile.png`, `empty-layout-drag.png` im selben Ordner. Keine Änderungen an Kundendaten durch die isolierten Tests.
|
||||
|
||||
**Bekannte Grenzen:** Der öffentliche Renderer ist unverändert; Lighthouse wurde für diese Fassung nicht erneut gemessen. Die Messung zu 1.6.1 bleibt unten dokumentiert. Kein neuer Screenreader-, Firefox-, Safari- oder echter Touchgerätetest. **Nächster Schritt:** Die neuen Aufbauabläufe mit Umuts tatsächlichen Seiten nutzen und anschließend die restlichen Arbeitspakete des Entwicklungsplans fortsetzen.
|
||||
|
||||
## 1.6.1 – Editorbreite, neue Seitenabschnitte und passende Eigenschaften (14.09.2026)
|
||||
|
||||
**Veröffentlicht am 14.09.2026:** Commit `b783d9d` und annotierter Tag `v1.6.1` sind auf `origin` (`https://git.kant-it.de/kantarci/KantSites.git`) hochgeladen. Das stabile [Release 1.6.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.1) enthält ZIP, SHA-256-Datei und `install.php`. Das Archiv wurde aus dem exakten Commit mit Produktionsabhängigkeiten gebaut (3520 Dateien); Archivintegrität, Übereinstimmung mit dem Commit und Produktionscontainer wurden geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; die API liefert 1.6.1 als neuestes stabiles Release. Archiv-SHA-256: `a6e7da2bb14ede43dd2d94960b3b4d67cc285d9c717b560f796a91cbcde4dd26`. Dieser Schritt veröffentlicht das Update, installiert es aber nicht auf Kundenwebsites.
|
||||
|
||||
**Erledigt:** Die festen 690/900/760-px-Grenzen für Text/H1/H2 sind entfernt. Elemente nutzen ihren Container, eigene Maximalbreiten bleiben möglich. Dauerhaft sichtbare Aktion **Neue Sektion auf Seitenebene**; auch der normale Sektionsklick erzeugt ohne expliziten Einfügepunkt einen übergeordneten Seitenabschnitt. Linke und rechte Sidebar sind per Ziehen und Tastatur verstellbar und merken ihre Breite im Browser. Die Vorschau nutzt ihre Fläche ohne Außenränder; die Elementbibliothek läuft nicht mehr horizontal über. Auf kleinen Fenstern passen sich die Panels an, auf dem Telefon stehen sie untereinander.
|
||||
|
||||
**Feinschliff:** Bild-, Text- und Containeroptionen werden passend gefiltert und sortiert. Gespeicherte Werte bleiben sichtbar; die gesamte Eigenschaftenauswahl ist über eine Schaltfläche erreichbar. Die Auswahl eines anderen Blocks setzt die Panelrolleiste nach oben. Geöffnete Eigenschaftengruppen bleiben beim Ändern eines Werts geöffnet. Automatische Größen und „Ohne Begrenzung“ sind explizit wählbar. Cachekennung auf 1.6.1 angehoben; keine Datenbankmigration, keine Kundeninhalte durch Tests geändert. Begründung und Rückweg: ADR 074.
|
||||
|
||||
**Geprüft:** `npm.cmd test`: 30/30; PHP 8.4.22 `vendor/bin/phpunit`: 452/452, 3476 Assertions. JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig; Produktionscache erfolgreich aufgewärmt, `git diff --check` sauber. `npm.cmd run test:editor -- --audit` vollständig bestanden: Seitenebenen/Undo/Redo, Panelbreiten per Maus/Tastatur und nach Neuladen, 390/850/1100/1920-px-Fenster, kontextbezogene Eigenschaften und bewusst gesetzte Textbreiten; auch Speichern, Neuladen, Veröffentlichen und Konfliktbehandlung. Sämtliche enthaltenen axe-Prüfungen ohne Verstoß. Desktop-, Bild- und Telefonansicht anhand der Browser-Screenshots visuell geprüft. Lighthouse **13.4.1**, isolierte öffentliche Editortestseite im Produktionsmodus: alle fünf Kategorien **100**, jeweils drei Läufe mobil und Desktop, auch jeder Einzellauf 100. Kein Nachweis für beliebige Kundeninhalte oder Feldwerte. Berichte: `reports/editor/result.json`, `reports/lighthouse-editor/summary.json`; Ansichten: `reports/editor/flexible-workspace.png`, `context-image.png`, `editor-phone.png`.
|
||||
|
||||
**Bekannte Grenzen:** Die geänderten Standardbreiten können den Zeilenumbruch bestehender Seiten verändern. Sektions- und ausdrücklich gespeicherte Elementbreiten bleiben wirksam. Kein neuer Screenreader- oder Felddatennachweis. **Nächster Schritt:** Umuts tatsächliche Editorabläufe mit realen Seiten durchgehen, danach den verbliebenen Ausbau gemäß Entwicklungsplan fortsetzen.
|
||||
|
||||
## Vorheriger Stand und bisherige Arbeitspakete
|
||||
|
||||
Stand: 14.09.2026, Fassung **1.6.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -4,9 +4,9 @@ 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
|
||||
// whole document. The selection tools therefore live outside it and get rebound after a refresh.
|
||||
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
|
||||
// The outline, element toolbar, insert points and drop marker 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
|
||||
@@ -50,10 +50,11 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
|
||||
const drop = element('div', 'ov-drop');
|
||||
const dropLabel = element('div', 'ov-drop-label');
|
||||
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);
|
||||
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop, dropLabel);
|
||||
stage.append(layer);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
@@ -206,7 +207,10 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) paintDrop();
|
||||
if (dragging) {
|
||||
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
|
||||
paintDrop();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
@@ -259,8 +263,9 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
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 };
|
||||
const horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
|
||||
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
|
||||
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (after ? 'bottom' : 'top') } : null };
|
||||
}
|
||||
if (!node) break;
|
||||
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
|
||||
@@ -276,11 +281,19 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
|
||||
function paintDrop() {
|
||||
const line = dropPlan?.line;
|
||||
if (!line?.box) { drop.hidden = true; return; }
|
||||
if (!line?.box) { drop.hidden = true; dropLabel.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
const scale = getScale();
|
||||
const thickness = 3 / scale;
|
||||
const { box, edge } = line;
|
||||
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
|
||||
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
|
||||
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
|
||||
dropLabel.hidden = false;
|
||||
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
|
||||
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
|
||||
counterScale(dropLabel, 'left', 'top');
|
||||
drop.style.marginLeft = drop.style.marginTop = '0';
|
||||
drop.classList.toggle('ov-drop-inside', edge === 'inside');
|
||||
if (edge === 'inside') {
|
||||
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
|
||||
@@ -317,15 +330,20 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
document_.addEventListener('dragover', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragging.kind === 'move' ? 'move' : 'copy';
|
||||
dragPoint = { x: event.clientX, y: event.clientY };
|
||||
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
|
||||
paintDrop();
|
||||
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
|
||||
});
|
||||
document_.addEventListener('dragleave', event => {
|
||||
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
|
||||
});
|
||||
document_.addEventListener('drop', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
// Both are read before the clean-up clears them.
|
||||
const payload = dragging, plan = dropPlan;
|
||||
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
endDrag();
|
||||
if (plan) onDrop(payload, plan);
|
||||
});
|
||||
@@ -335,17 +353,35 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
function startDrag(payload) {
|
||||
dragging = payload;
|
||||
layer.classList.add('is-dragging');
|
||||
doc()?.documentElement.classList.add('editor-dragging');
|
||||
hovered = null;
|
||||
sync();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragging = null;
|
||||
dragPoint = null;
|
||||
cancelAnimationFrame(scrollFrame); scrollFrame = null;
|
||||
dropPlan = null;
|
||||
drop.hidden = true;
|
||||
dropLabel.hidden = true;
|
||||
layer.classList.remove('is-dragging');
|
||||
doc()?.documentElement.classList.remove('editor-dragging');
|
||||
sync();
|
||||
}
|
||||
|
||||
let dragPoint = null, scrollFrame = null;
|
||||
function scrollDrag() {
|
||||
scrollFrame = null;
|
||||
if (!dragging || !dragPoint) return;
|
||||
const edge = 60 / getScale();
|
||||
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
|
||||
if (amount) {
|
||||
doc()?.defaultView?.scrollBy(0, amount / getScale());
|
||||
sync();
|
||||
scrollFrame = requestAnimationFrame(scrollDrag);
|
||||
}
|
||||
}
|
||||
|
||||
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
export const GROUPS = {
|
||||
@@ -122,6 +122,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
const linkedSides = new Map();
|
||||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||||
let clipboard = null;
|
||||
let showAll = false, lastBlockId = null;
|
||||
const openGroups = new Map();
|
||||
const renderedGroups = new Map();
|
||||
|
||||
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
|
||||
const applyStyle = (doc, id, style) => {
|
||||
@@ -227,7 +230,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder) => {
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
const parts = splitLength(value);
|
||||
const number = document.createElement('input');
|
||||
@@ -245,6 +248,17 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||||
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
|
||||
wrap.append(number, unit);
|
||||
if (keywords.length) {
|
||||
const mode = document.createElement('select');
|
||||
mode.className = 'length-mode';
|
||||
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
|
||||
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
|
||||
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
|
||||
mode.value = ['auto', 'none'].includes(value) ? value : '';
|
||||
number.hidden = unit.hidden = mode.value !== '';
|
||||
mode.addEventListener('change', () => apply(mode.value));
|
||||
wrap.prepend(mode);
|
||||
}
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -446,14 +460,22 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
/** Renders the whole panel for one block into the given container. */
|
||||
return function render(panel) {
|
||||
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
|
||||
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
|
||||
renderedGroups.clear();
|
||||
const block = getBlock();
|
||||
const deviceName = getDevice();
|
||||
if (!block) return;
|
||||
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
|
||||
|
||||
const scope = element('p', 'design-scope');
|
||||
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
|
||||
panel.append(scope);
|
||||
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
|
||||
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
|
||||
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
|
||||
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
|
||||
panel.append(all);
|
||||
|
||||
// A filter over the properties. With more than ninety of them across eight collapsed groups,
|
||||
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
|
||||
@@ -484,11 +506,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
});
|
||||
panel.append(searchLabel, search, found);
|
||||
|
||||
for (const [group, groupLabel] of Object.entries(GROUPS)) {
|
||||
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||||
for (const group of orderedGroups) {
|
||||
const groupLabel = GROUPS[group];
|
||||
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
|
||||
if (properties.length === 0) continue;
|
||||
const details = element('details', 'design-group');
|
||||
if (group === 'text' || group === 'spacing') details.open = true;
|
||||
const groupKey = `${block.type}:${group}`;
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
|
||||
renderedGroups.set(groupKey, details);
|
||||
details.append(element('summary', null, groupLabel));
|
||||
const body = element('div', 'design-group-body');
|
||||
|
||||
@@ -510,7 +537,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
|
||||
@@ -141,6 +141,11 @@ export function upgradeForType(document, type) {
|
||||
* @return {{parentId: string|null, index: number}|null}
|
||||
*/
|
||||
export function insertionTarget(document, selected, type) {
|
||||
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
|
||||
if (type === 'section') {
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
|
||||
}
|
||||
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 };
|
||||
@@ -533,3 +538,13 @@ export function copyStyle(document, fromId, toId) {
|
||||
target.style = structuredClone(source.style);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** UI relevance only. Existing values remain editable, including on older documents. */
|
||||
export function relevantProperty(block, key, property) {
|
||||
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined)) return true;
|
||||
const container = isContainer(block);
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
@@ -246,6 +246,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.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}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* 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}
|
||||
|
||||
@@ -281,3 +282,30 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
|
||||
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
|
||||
#library-found{margin:0 0 10px;min-height:0}
|
||||
|
||||
/* Flexible workspace: both panels use their full width, with accessible resize handles. */
|
||||
.editor-layout{grid-template-columns:var(--panel-left,280px) 6px minmax(280px,1fr) 6px var(--panel-right,320px)}
|
||||
.left-panel,.right-panel{min-width:0;overflow-x:hidden}
|
||||
.left-panel{container-type:inline-size}
|
||||
.left-panel>.section-launch{flex:none;padding:12px;border-bottom:1px solid var(--line)}
|
||||
.section-launch button{white-space:normal;font-size:12px;min-height:40px}
|
||||
.section-launch .field-help{margin:6px 0 0}
|
||||
.left-panel>.panel-section:not(.structure-section){max-height:55%;overflow:auto;flex-shrink:0}
|
||||
.panel-section{padding:16px 12px}
|
||||
.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.library-buttons button{min-width:0;white-space:normal;overflow-wrap:anywhere;gap:6px}
|
||||
.library-name{overflow-wrap:anywhere}
|
||||
.library-preview{width:28px}
|
||||
@container(max-width:270px){.library-buttons{grid-template-columns:minmax(0,1fr)}.library-buttons button[data-row]{grid-column:auto}}
|
||||
.canvas-scroll{padding:0}
|
||||
.panel-resizer{background:#e4e9df;cursor:col-resize;touch-action:none;position:relative;z-index:4}
|
||||
.panel-resizer::after{content:'';position:absolute;top:45%;left:1px;width:4px;height:36px;border-radius:4px;background:#7b8d80}
|
||||
.panel-resizer:hover,.panel-resizer:focus-visible{background:#b7cbbd;outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.resizing-panels{cursor:col-resize;user-select:none}
|
||||
.resizing-panels iframe{pointer-events:none}
|
||||
.length-control,.number-control{grid-template-columns:minmax(0,1fr) 74px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{min-width:0;max-width:100%}
|
||||
.length-mode{grid-column:1/-1}
|
||||
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
|
||||
@media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}}
|
||||
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
|
||||
+122
-10
@@ -4,6 +4,7 @@ import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
import { preview, rowPreview } from './library-previews.js';
|
||||
import { decorateContainers } from './preview-containers.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'];
|
||||
@@ -23,6 +24,8 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
|
||||
// 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();
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
$('#message').textContent = message;
|
||||
@@ -72,6 +75,7 @@ function controls() {
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#select-page').disabled = !ready || busy;
|
||||
$('#add-root-section').disabled = !ready || busy;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
}
|
||||
|
||||
@@ -112,6 +116,7 @@ function renderTree() {
|
||||
|
||||
function choose(id, scroll = true) {
|
||||
if (busy) return;
|
||||
if (id !== selected && pendingInsert) setPendingInsert(null);
|
||||
selected = locate(history.document.blocks, id) ? id : null;
|
||||
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
||||
if (!selected) propertyPanel = 'content';
|
||||
@@ -131,6 +136,9 @@ function highlight(scroll = false) {
|
||||
function renderProperties() {
|
||||
const block = locate(history.document.blocks, selected)?.block;
|
||||
if (!block) propertyPanel = 'content';
|
||||
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
||||
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
|
||||
renderedProperties = context;
|
||||
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
|
||||
const panel = $('#properties'); panel.replaceChildren();
|
||||
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||||
@@ -325,7 +333,7 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal (1180 px)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos heißt ohne Rand und ohne Begrenzung – für ein Bild oder eine Farbfläche von Kante zu Kante.' });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,9 +553,9 @@ function stopInlineEdit() {
|
||||
|
||||
function resizePreview() {
|
||||
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||||
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
|
||||
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||
const available = Math.max(100, container.clientWidth - padding);
|
||||
const width = { desktop: Math.max(1240, available), tablet: 768, mobile: 390 }[device];
|
||||
const scale = Math.min(1, available / width);
|
||||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||
previewScale = scale;
|
||||
@@ -590,7 +598,13 @@ async function updatePreview() {
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
|
||||
angewandt = true;
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = `/assets/preview.css?v=${new URL(import.meta.url).searchParams.get('v') ?? 'dev'}`;
|
||||
// Register before insertion: cached loads (or errors) can precede DOMContentLoaded.
|
||||
const styleReady = new Promise(resolve => {
|
||||
style.addEventListener('load', () => resolve(true), { once: true });
|
||||
style.addEventListener('error', () => resolve(false), { once: true });
|
||||
});
|
||||
doc.head.append(style);
|
||||
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
|
||||
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
|
||||
// preview shows exactly what the published page shows, without a single blocked style.
|
||||
@@ -602,6 +616,22 @@ async function updatePreview() {
|
||||
}
|
||||
doc.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
const insert = event.target.closest('[data-editor-target]');
|
||||
if (insert && !busy && history) {
|
||||
stopInlineEdit();
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
choose(id, false);
|
||||
if (insert.hasAttribute('data-editor-row')) {
|
||||
setPendingInsert(null);
|
||||
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
|
||||
propertyPanel = 'content'; changed();
|
||||
} else if (locate(history.document.blocks, id).block.type === 'row') {
|
||||
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
|
||||
changed();
|
||||
} else setPendingInsert({ parentId: id, index: 0 });
|
||||
return;
|
||||
}
|
||||
if (inlineEdit) return;
|
||||
const element = event.target.closest('[id]');
|
||||
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||||
@@ -613,12 +643,23 @@ 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 · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.';
|
||||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||||
const finish = () => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
decorateContainers(doc, history.document);
|
||||
overlay?.attach();
|
||||
styleReady.then(loaded => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
frame.contentWindow.scrollTo(0, top);
|
||||
highlight(revealAfterPreview === selected); revealAfterPreview = null;
|
||||
markInsertTarget();
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = loaded
|
||||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||||
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
|
||||
});
|
||||
};
|
||||
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', finish, { once: true }); else finish();
|
||||
};
|
||||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||||
frame.style.visibility = 'hidden';
|
||||
@@ -649,6 +690,7 @@ async function updatePreview() {
|
||||
|
||||
function changed() {
|
||||
if (!locate(history.document.blocks, selected)) selected = null;
|
||||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
@@ -722,12 +764,23 @@ function setPendingInsert(target) {
|
||||
updateLibrary();
|
||||
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
|
||||
$('#insert-hint').hidden = !target;
|
||||
markInsertTarget();
|
||||
if (target) {
|
||||
if ($('#toolbox').hidden) $('#toggle-library').click();
|
||||
$('#toolbox').scrollIntoView({ block: 'nearest' });
|
||||
$('#library-search').value = ''; filterLibrary();
|
||||
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
|
||||
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
|
||||
$('#library-search').focus({ preventScroll: true });
|
||||
$('#library-search').scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function markInsertTarget() {
|
||||
const doc = $('#preview').contentDocument;
|
||||
doc?.querySelectorAll('.editor-insert-target').forEach(node => node.classList.remove('editor-insert-target'));
|
||||
if (pendingInsert?.parentId) doc?.getElementById(pendingInsert.parentId)?.classList.add('editor-insert-target');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The toolbox.
|
||||
//
|
||||
@@ -830,6 +883,13 @@ $('#library').addEventListener('click', event => {
|
||||
addElement(button.dataset.add);
|
||||
});
|
||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
||||
$('#add-root-section').addEventListener('click', () => {
|
||||
if (busy || !history) return;
|
||||
stopInlineEdit();
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => insertBlock(doc, 'section', selected));
|
||||
propertyPanel = 'content'; changed();
|
||||
});
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
if (!name || busy || !history) return;
|
||||
@@ -1003,6 +1063,58 @@ function setViewport(name) {
|
||||
}
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
||||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||
// Panel sizes are workspace preferences, independent of document history and publication.
|
||||
const panelSizes = { left: 280, right: 320 };
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
|
||||
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
|
||||
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
|
||||
const panelMinimum = { left: 240, right: 280 };
|
||||
function layoutPanels() {
|
||||
let left = Math.max(240, Math.min(560, panelSizes.left));
|
||||
let right = Math.max(280, Math.min(560, panelSizes.right));
|
||||
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
|
||||
const excess = Math.max(0, left + right - budget);
|
||||
const spare = left + right - 520;
|
||||
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
|
||||
for (const [side, size] of Object.entries({ left, right })) {
|
||||
root.style.setProperty(`--panel-${side}`, `${size}px`);
|
||||
const handle = $(`[data-resize="${side}"]`);
|
||||
handle.setAttribute('aria-valuenow', String(Math.round(size)));
|
||||
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
|
||||
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
|
||||
}
|
||||
}
|
||||
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
|
||||
for (const handle of document.querySelectorAll('[data-resize]')) {
|
||||
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
|
||||
let drag = null;
|
||||
const setSize = value => {
|
||||
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
|
||||
layoutPanels();
|
||||
};
|
||||
handle.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); handle.focus();
|
||||
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
|
||||
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
|
||||
});
|
||||
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
|
||||
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
handle.addEventListener('lostpointercapture', stop);
|
||||
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
|
||||
handle.addEventListener('keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const current = Number(handle.getAttribute('aria-valuenow'));
|
||||
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
|
||||
persistPanels();
|
||||
});
|
||||
}
|
||||
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
|
||||
layoutPanels();
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { blockLabel } from './editor-model.js';
|
||||
|
||||
/** Editing aids are created only in the preview DOM, never in a saved block or public HTML. */
|
||||
export function decorateContainers(preview, content) {
|
||||
const visit = (blocks, parent = null) => {
|
||||
blocks.forEach((block, index) => {
|
||||
const node = preview.getElementById(block.id);
|
||||
if (node && ['section', 'row', 'column'].includes(block.type)) {
|
||||
const label = block.type === 'column' && parent?.type === 'row'
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
|
||||
node.classList.add('editor-container');
|
||||
node.dataset.editorLabel = label;
|
||||
if (!block.children?.length) {
|
||||
node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
|
||||
if (!host) return;
|
||||
const empty = preview.createElement('div');
|
||||
empty.className = 'editor-empty';
|
||||
const name = preview.createElement('span');
|
||||
name.className = 'editor-empty-name'; name.textContent = label;
|
||||
const hint = preview.createElement('span');
|
||||
hint.className = 'editor-empty-hint';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
|
||||
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
|
||||
const add = (text, attribute, value, accessibleName) => {
|
||||
const button = preview.createElement('button');
|
||||
button.type = 'button'; button.textContent = text;
|
||||
button.dataset.editorTarget = block.id;
|
||||
button.setAttribute(attribute, value);
|
||||
button.setAttribute('aria-label', accessibleName);
|
||||
actions.append(button);
|
||||
};
|
||||
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
|
||||
if (block.type === 'section') {
|
||||
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
|
||||
}
|
||||
empty.append(name, hint, actions);
|
||||
host.append(empty);
|
||||
}
|
||||
}
|
||||
visit(block.children ?? [], block);
|
||||
});
|
||||
};
|
||||
visit(content.blocks);
|
||||
}
|
||||
@@ -7,3 +7,17 @@
|
||||
|
||||
/* Inline editing. Only ever active in the editor preview, never on a published page. */
|
||||
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
|
||||
|
||||
/* Empty layout areas must have a real hit area, including when sibling columns have content. */
|
||||
.editor-empty-container{min-height:130px!important}
|
||||
.editor-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;min-height:130px;width:100%;padding:22px 14px;border:2px dashed #93aa9a;border-radius:7px;background:#f3f8f4;color:#264f3d;text-align:center;font:14px/1.5 system-ui,sans-serif;box-sizing:border-box;grid-column:1/-1}
|
||||
.editor-empty-name{font-size:12px;font-weight:700;letter-spacing:.04em}
|
||||
.editor-empty-hint{font-size:13px;color:#496351}
|
||||
.editor-empty-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}
|
||||
.editor-empty button{appearance:none;font:600 13px/1.3 system-ui,sans-serif;color:#264f3d;background:white;border:1px solid #94ad9d;border-radius:6px;min-height:40px;padding:9px 13px;cursor:pointer;letter-spacing:normal;text-transform:none;box-shadow:none;margin:0}
|
||||
.editor-empty button:hover,.editor-empty button:focus-visible{background:#deeee2;border-color:#32644a;outline:2px solid #32644a;outline-offset:2px}
|
||||
.editor-empty button[data-editor-insert]{background:#2c634a;color:white;border-color:#2c634a}
|
||||
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
|
||||
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@@ -6,7 +6,7 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
|
||||
@@ -104,6 +104,178 @@ try {
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
// Flexible panels, contextual fields and a root insertion from a deeply selected element.
|
||||
const leftHandle = page.locator('[data-resize="left"]'), rightHandle = page.locator('[data-resize="right"]');
|
||||
await leftHandle.focus(); await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
||||
const rightBox = await rightHandle.boundingBox();
|
||||
await page.mouse.move(rightBox.x + 3, rightBox.y + 100); await page.mouse.down();
|
||||
await page.mouse.move(rightBox.x - 57, rightBox.y + 100, { steps: 6 }); await page.mouse.up();
|
||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
||||
await page.reload();
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
||||
for (const width of [1920, 1100, 850, 390]) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
|
||||
for (const selector of ['.left-panel', '.right-panel', '.library-buttons']) {
|
||||
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await frame.locator('h1').click();
|
||||
const sectionCount = await frame.locator('main > .block-section').count();
|
||||
await page.locator('#add-root-section').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||
await page.locator('#undo').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
|
||||
await page.locator('#redo').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||
await page.locator('#undo').click();
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('#library [data-add="image"]').click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
assert.equal(await page.locator('#style-fontSize').count(), 0);
|
||||
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/context-image.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Alle Eigenschaften anzeigen', exact: true }).click();
|
||||
assert.equal(await page.locator('#style-fontSize').count(), 1);
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('main .block-text').first().click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
assert.equal(await page.locator('#style-objectFit').count(), 0);
|
||||
assert.equal(await page.locator('#style-fontSize').isVisible(), true);
|
||||
const textWidth = await frame.locator('main .block-text').first().evaluate(el => ({ width: el.getBoundingClientRect().width, parent: el.parentElement.clientWidth }));
|
||||
assert.ok(Math.abs(textWidth.width - textWidth.parent) < 2, 'text uses its container width');
|
||||
const sizeGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Größe$/ }) });
|
||||
await sizeGroup.locator('summary').click();
|
||||
await page.locator('#style-maxWidth').locator('xpath=following-sibling::select').selectOption('ch');
|
||||
await page.locator('#style-maxWidth').fill('60');
|
||||
await page.locator('#style-maxWidth').blur();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
||||
return Boolean(el && el.getBoundingClientRect().width < el.parentElement.clientWidth - 100);
|
||||
});
|
||||
await page.getByLabel('Maximale Breite Vorgabe', { exact: true }).selectOption('none');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
||||
return Boolean(el && getComputedStyle(el).maxWidth === 'none');
|
||||
});
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
|
||||
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
|
||||
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
|
||||
await leftHandle.dblclick(); await rightHandle.dblclick();
|
||||
await page.locator('[data-panel="content"]').click();
|
||||
checks.push({ name: 'resizable persistent panels, root sections, contextual image/text properties and full text width', passed: true });
|
||||
|
||||
// A layout starts empty: it must be visible, reachable and editable before it has any text.
|
||||
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('#add-root-section').click();
|
||||
await previewReady();
|
||||
const emptySection = frame.locator('main > .editor-empty-container');
|
||||
await emptySection.waitFor();
|
||||
const newSectionId = await emptySection.getAttribute('id');
|
||||
assert.ok((await emptySection.boundingBox()).height > 100, 'new empty section has a visible hit area');
|
||||
await page.screenshot({ path: 'reports/editor/empty-section.png', fullPage: true });
|
||||
await frame.getByRole('button', { name: '2 Spalten in dieser Sektion anlegen', exact: true }).press('Enter');
|
||||
await previewReady();
|
||||
const emptyColumns = frame.locator(`#${newSectionId} .block-column`);
|
||||
assert.equal(await emptyColumns.count(), 2);
|
||||
for (let index = 0; index < 2; index++) assert.ok((await emptyColumns.nth(index).boundingBox()).height > 80, 'empty column has a hit area');
|
||||
const secondColumnId = await emptyColumns.nth(1).getAttribute('id');
|
||||
const firstColumnId = await emptyColumns.first().getAttribute('id');
|
||||
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).press('Enter');
|
||||
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
|
||||
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
|
||||
await page.locator('#library-search').fill('Text');
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
|
||||
await previewReady();
|
||||
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
|
||||
await page.locator('#library-search').fill('');
|
||||
const dragSource = page.locator('#library [data-add="button"]');
|
||||
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
||||
await dragSource.scrollIntoViewIfNeeded();
|
||||
const sourceBox = await dragSource.boundingBox();
|
||||
let targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
|
||||
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 10, sourceBox.y + sourceBox.height / 2, { steps: 3 });
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 });
|
||||
// Chromium dispatches dragenter on the first move into a target, dragover on the next.
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
|
||||
await page.locator('.ov-drop-label').filter({ hasText: 'Spalte 1 von 2' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/empty-layout-drag.png', fullPage: true });
|
||||
const beforeDragScroll = await page.locator('#preview').evaluate(el => el.contentWindow.scrollY);
|
||||
const frameBox = await page.locator('#preview').boundingBox();
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8, { steps: 5 });
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8);
|
||||
await page.waitForFunction(top => document.querySelector('#preview').contentWindow.scrollY > top + 10, beforeDragScroll);
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
|
||||
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
||||
targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 6 });
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
|
||||
await page.mouse.up();
|
||||
await frame.locator(`#${firstColumnId} .button`).waitFor();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 0, 'placeholders disappear after filling');
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'undo restores the empty target');
|
||||
await page.locator('#redo').click(); await previewReady();
|
||||
await frame.locator(`#${firstColumnId} .button`).waitFor();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
await page.screenshot({ path: 'reports/editor/empty-layout-desktop.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: 'reports/editor/empty-layout-mobile.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await previewReady();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 2, 'saved empty columns survive reload');
|
||||
for (const device of ['Desktop', 'Mobil']) {
|
||||
await page.getByRole('button', { name: device, exact: true }).click();
|
||||
const destination = frame.locator(`#${secondColumnId}`);
|
||||
await destination.scrollIntoViewIfNeeded();
|
||||
const box = await destination.boundingBox();
|
||||
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
|
||||
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
|
||||
});
|
||||
await previewReady();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), secondColumnId, `column order follows the ${device} layout`);
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
|
||||
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
|
||||
const publicBefore = await (await fetch(origin)).text();
|
||||
assert.equal(publicBefore.includes(newSectionId), false, 'draft remains private');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const publishedEmpty = await (await fetch(origin)).text();
|
||||
assert.ok(publishedEmpty.includes(newSectionId));
|
||||
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
|
||||
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
|
||||
await page.locator(`#structure [data-block-id="${newSectionId}"]`).click();
|
||||
await page.locator('#delete').click();
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
await page.reload(); await previewReady();
|
||||
checks.push({ name: 'empty sections and columns: keyboard insertion, exact drop target, undo, responsive preview, reload and public isolation', passed: true });
|
||||
await frame.locator('h1').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews'] %}
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<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">
|
||||
<div class="section-launch"><button id="add-root-section" class="secondary full-width" disabled>+ Neue Sektion auf Seitenebene</button><p class="field-help">Nach dem aktuellen Abschnitt, außerhalb des gewählten Inhalts.</p></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>
|
||||
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
|
||||
@@ -33,12 +34,14 @@
|
||||
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
|
||||
</section>
|
||||
</aside>
|
||||
<div class="panel-resizer" data-resize="left" role="separator" tabindex="0" aria-label="Linke Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="240" aria-valuemax="560" aria-valuenow="280" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
|
||||
<section class="canvas-panel" aria-label="Live-Vorschau">
|
||||
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><a href="{{ page.slug == 'home' ? '/' : '/' ~ page.slug }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a></div>
|
||||
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
|
||||
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
|
||||
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
|
||||
</section>
|
||||
<div class="panel-resizer" data-resize="right" role="separator" tabindex="0" aria-label="Rechte Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="560" aria-valuenow="320" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
|
||||
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.</div></aside>
|
||||
</main>
|
||||
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
|
||||
|
||||
@@ -4,6 +4,30 @@ import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
import { relevantProperty } from '../public/assets/editor-model.js';
|
||||
|
||||
test('sections start after the containing root, explicit placement can nest, undo restores the tree', () => {
|
||||
const original = fixture();
|
||||
const history = new EditorHistory(original);
|
||||
const id = history.change(doc => insertBlock(doc, 'section', 't1'));
|
||||
assert.equal(history.document.blocks[1].id, id);
|
||||
assert.equal(locate(history.document.blocks, id).parent, null);
|
||||
history.undo(); assert.deepEqual(history.document, original);
|
||||
history.redo(); assert.equal(history.document.blocks[1].id, id);
|
||||
const nested = history.change(doc => insertBlockAt(doc, 'section', 's1', 0));
|
||||
assert.equal(locate(history.document.blocks, nested).parent.id, 's1');
|
||||
});
|
||||
|
||||
test('design fields follow block type without hiding existing stored values', () => {
|
||||
const text = createBlock('text'), image = createBlock('image');
|
||||
assert.equal(relevantProperty(text, 'objectFit', { group: 'image' }), false);
|
||||
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), false);
|
||||
assert.equal(relevantProperty(image, 'objectFit', { group: 'image' }), true);
|
||||
assert.equal(relevantProperty(text, 'fontSize', { group: 'text' }), true);
|
||||
assert.equal(relevantProperty(text, 'gridColumns', { group: 'layout' }), false);
|
||||
image.style = { mobile: { fontSize: '14px' } };
|
||||
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), true);
|
||||
});
|
||||
|
||||
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user