Fassung 1.5.2: die Optionen selbst
Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten. Geraeteschalter je Option. Bisher entschied ein einziger Schalter ueber der Vorschau, welches Geraet das ganze Bedienfeld bearbeitet; "ist die Ueberschrift auf dem Telefon kleiner" liess sich nur durch Umschalten und Hinsehen beantworten. Jetzt traegt jede Option drei Chips (D, T, M): sie zeigen, wo ein eigener Wert liegt, und ein Klick wechselt dorthin. Bedienfeld und Vorschau wechseln gemeinsam - einen Wert fuer ein Geraet zu setzen, das man nicht ansieht, ist blindes Aendern. Abstand als Rahmen. Vier Felder untereinander, beschriftet "Oben, Rechts, Unten, Links", zwangen zum Lesen. Jetzt sitzt jedes Feld an seiner Kante, die Lage ist die Beschriftung; in der Mitte verkettet ein Schalter alle vier. Er startet aus: er ersetzt den frueheren Knopf "Alle Seiten gleich setzen", und das war eine bewusste Handlung. Seiten mit unterschiedlichen Einheiten gelten als "gemischt" und bleiben unangetastet - ein gespeichertes 2rem wird nicht still zu 2px. Ziehfelder. Zahlenfelder lassen sich seitwaerts ziehen, Umschalt grob, Alt fein. Tippen und Pfeiltasten bleiben unveraendert. Nur fuer Maus und Stift: auf einem Tippgeraet ist ein seitliches Ziehen ueber ein Feld die Art, wie das Bedienfeld gescrollt wird. Dabei behoben: der Zug war nach einem Pixel zu Ende, weil jede Aenderung das Bedienfeld neu zeichnete und damit das Feld ersetzte, an dem der Zeiger hing (keepPanel). Der Kettenschalter verlor aus demselben Grund seinen Zustand. Und das Gleichziehen nahm den Wert des ersten Feldes, auch wenn es leer war - ein Druck auf die Kette loeschte damit die Seiten, die einen Wert hatten. Geprueft: npm test 27/27, phpunit 452/452, npm run test:editor vollstaendig mit neuem Abschnitt und ohne axe-Verstoss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e2334d30f
commit
05c947c8ab
6 files changed
+387
-48
No files matched your search
+51
-4
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 14.09.2026, Fassung **1.5.1**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 14.09.2026, Fassung **1.5.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.
|
||||
|
||||
@@ -321,11 +321,58 @@ werden nicht nachträglich geprüft: eine Überschrift, die früher direkt in ei
|
||||
ist, wird weiterhin ausgegeben und lässt sich auswählen und löschen, nur nicht mehr dorthin
|
||||
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
|
||||
|
||||
Arbeitspaket H, Teil 3: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
|
||||
Vorschaubild, Geräteschalter an der einzelnen Option, Ziehfelder für Zahlen, Abstandsrahmen und
|
||||
das Ziehen der Spaltenbreite in der Vorschau. Danach
|
||||
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
|
||||
Vorschaubild und das Ziehen der Spaltenbreite in der Vorschau. Danach
|
||||
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
|
||||
Begründung in `docs/FLATSOME-ABGLEICH.md`.
|
||||
|
||||
|
||||
+225
-33
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +146,33 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
|
||||
.number-control 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%}
|
||||
|
||||
|
||||
+16
-6
@@ -658,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,
|
||||
});
|
||||
@@ -962,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;
|
||||
|
||||
@@ -273,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');
|
||||
|
||||
Reference in new issue
Block a user