Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen

Inline-Textbearbeitung ohne Skript in der Vorschau: der Iframe führt
weiterhin kein eigenes JavaScript aus, der Editor greift in sein
Dokument und setzt das Element auf contenteditable. Während der
Bearbeitung wird die Vorschau nicht neu geladen, sonst wäre der
Cursor weg; Eingaben werden zu einem Undo-Schritt zusammengefasst.
Neben dem Doppelklick startet eine Schaltfläche dasselbe ohne Maus.

Kontrast wird nach WCAG 2.1 berechnet und als Warnung in drei Stufen
gemeldet, nicht als Verbot - der Plan verlangt Prüfung, nicht
Bevormundung.

Gestaltungsvorlagen liegen im Theme und gelten seitenübergreifend.
Sie werden mit demselben Compiler geprüft wie ein Blockstil, also
kann über diesen Weg kein Wert hereinkommen, für den ein Block
abgewiesen würde. Dazu Kopieren und Einfügen zwischen Blöcken und
Bildauswahl für Hintergrundbilder über die Mediathek.

Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Lint/Format/Container. Erstmals wurde der
neue Editor im laufenden Betrieb bedient: Direktbearbeitung und
Kontrastwarnung mit echten Werten ausgelöst, Teständerungen
verworfen. Weiterhin keine axe- oder Lighthouse-Messung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 13:20:01 +02:00
1 parent abf0bd2c6b
commit 270cbda9ac
11 files changed
+388 -10

No files matched your search

+43 -2
View File
@@ -14,6 +14,7 @@ final class Theme
{
public const MAX_COLORS = 40;
public const MAX_FONTS = 8;
public const MAX_PRESETS = 40;
/** Weights Google serves for most families; also the range a designer realistically needs. */
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
@@ -57,10 +58,15 @@ final class Theme
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
return self::DEFAULTS;
return [...self::DEFAULTS, 'presets' => []];
}
return ['colors' => $stored['colors'], 'fonts' => $stored['fonts'], 'base' => [...self::DEFAULTS['base'], ...$stored['base']]];
return [
'colors' => $stored['colors'],
'fonts' => $stored['fonts'],
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
'presets' => is_array($stored['presets'] ?? null) ? $stored['presets'] : [],
];
}
public function save(array $colors, array $fonts, array $base): void
@@ -69,6 +75,8 @@ final class Theme
'colors' => $this->colors($colors),
'fonts' => $this->fonts($fonts),
'base' => $this->base($base),
// Saving the palette must not drop the presets an editor has collected.
'presets' => $this->load()['presets'],
];
$keys = array_column($theme['colors'], 'key');
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
@@ -198,6 +206,39 @@ final class Theme
return $base;
}
/**
* Stores a reusable style under a name, or removes it when the style is null. The style is
* validated with the same compiler the documents use, so a preset can never carry a value a
* block would be refused for.
*/
public function savePreset(string $name, ?array $style, StyleCompiler $compiler): void
{
$name = trim($name);
if ('' === $name || mb_strlen($name) > 60) {
throw new InvalidPage('Eine Vorlage braucht einen Namen mit höchstens 60 Zeichen.');
}
$key = trim(preg_replace('/[^a-z0-9]+/', '-', mb_strtolower($name, 'UTF-8')) ?? '', '-');
if ('' === $key) {
throw new InvalidPage('Aus diesem Namen lässt sich kein Schlüssel bilden.');
}
if (null !== $style) {
$compiler->validate($style);
}
$theme = $this->load();
$presets = array_values(array_filter($theme['presets'], static fn (array $p): bool => $p['key'] !== $key));
if (null !== $style) {
if (count($presets) >= self::MAX_PRESETS) {
throw new InvalidPage(sprintf('Mehr als %d Vorlagen sind nicht vorgesehen.', self::MAX_PRESETS));
}
$presets[] = ['key' => $key, 'name' => $name, 'style' => $style];
}
usort($presets, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
}
/** The stylesheet every page carries: palette variables, font variables and base typography. */
public function css(array $theme): string
{
+23
View File
@@ -83,9 +83,32 @@ final class EditorController extends AbstractController
'hoverable' => StyleSchema::hoverable(),
'colors' => $theme['colors'],
'fonts' => $theme['fonts'],
'presets' => $theme['presets'],
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
]);
}
/** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */
#[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])]
public function stylePreset(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
try {
$body = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
$style = $body['style'] ?? null;
$this->theme->savePreset((string) ($body['name'] ?? ''), is_array($style) ? $style : null, $this->styles);
} catch (InvalidPage|InvalidDocument $e) {
return $this->json(['error' => $e->getMessage()], 422);
} catch (\JsonException) {
return $this->json(['error' => 'Die Anfrage ist unvollständig.'], 422);
}
return $this->json(['presets' => $this->theme->load()['presets']]);
}
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
public function metadata(Request $request, string $id): Response
{