Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
This commit is contained in:
1 parent
b164c4edfe
commit
495b26896e
55 files changed
+1632
-423
No files matched your search
@@ -705,3 +705,29 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.shell-version { display: none }
|
||||
.shell-user strong { display: none }
|
||||
}
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
||||
.picker-results{position:absolute;z-index:30;top:100%;left:0;right:0;min-width:220px;max-height:300px;overflow-y:auto;background:white;border:1px solid #a7b9af;border-radius:8px;box-shadow:0 10px 30px #18382b24;padding:5px}
|
||||
.picker-results[hidden]{display:none}.picker-results button{display:flex;flex-direction:column;align-items:start;width:100%;padding:10px;text-align:left;color:#163d30;background:white;border:0;border-radius:5px}
|
||||
.picker-results button:hover,.picker-results button[aria-selected=true]{background:#e8f2e9}.picker-results span{font-size:12px;color:#496458;overflow-wrap:anywhere}.picker-status:empty{display:none}
|
||||
@media(max-width:650px){.list-toolbar>div,.list-toolbar .list-search{min-width:0;flex-basis:100%}.picker-results{min-width:0}.picker-input button{min-width:44px;min-height:44px}}
|
||||
|
||||
/* One menu at a time: content picker on the left, ordered structure on the right. */
|
||||
.menu-slots{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}.menu-slots a{padding:10px 16px;border:1px solid var(--line);border-radius:8px;text-decoration:none;background:white}.menu-slots a[aria-current=page]{background:var(--shell);color:white;border-color:var(--shell)}
|
||||
.menu-workspace{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:24px;align-items:start}.menu-add{display:grid;gap:16px;position:sticky;top:20px}.menu-add .card{margin:0}.menu-add .content-picker{margin-bottom:16px}.menu-structure{min-width:0}.menu-items{list-style:none;padding:0;margin:24px 0;display:grid;gap:12px}.menu-entry{border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.menu-entry.menu-child{margin-left:32px;border-left:3px solid #8eaa99}.menu-entry summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;cursor:pointer}.menu-entry summary::after{content:'⌄';font-size:18px}.menu-entry-title{overflow-wrap:anywhere;flex:1}.menu-entry-fields{padding:0 16px 16px}.menu-entry-fields .content-picker{margin-top:14px}.menu-entry-actions{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;border-top:1px solid var(--line);background:#f7f9f6;border-radius:0 0 9px 9px}.menu-entry-actions button{min-height:36px}.menu-entry-actions .danger-text{margin-left:auto}.menu-save{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding-top:16px;border-top:1px solid var(--line)}.menu-save [role=status]{font-size:13px;color:var(--muted)}
|
||||
@media(max-width:1100px){.menu-workspace{grid-template-columns:minmax(220px,280px) minmax(0,1fr)}}
|
||||
@media(max-width:850px){.menu-workspace{grid-template-columns:1fr}.menu-add{position:static;grid-template-columns:1fr 1fr}.menu-add>p{grid-column:1/-1}}
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
|
||||
@media(max-width:1100px){
|
||||
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
|
||||
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
|
||||
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
@@ -309,3 +309,5 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.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}}
|
||||
|
||||
.canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px}
|
||||
+26
-5
@@ -5,6 +5,8 @@ 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';
|
||||
import { initMotion } from './motion.js';
|
||||
import { initSliders } from './slider.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'];
|
||||
@@ -16,6 +18,7 @@ const $ = selector => document.querySelector(selector);
|
||||
// same document, draft and preview endpoints, so this file does not care which one it drives.
|
||||
const api = root.dataset.api;
|
||||
const editingArea = 'page' !== root.dataset.kind;
|
||||
const adminBase = document.querySelector('meta[name="admin-base"]').content;
|
||||
const mayScript = 'true' === root.dataset.canScript;
|
||||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||
@@ -26,6 +29,21 @@ let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
let previewPlayback = false, stopPlayback = () => {};
|
||||
|
||||
function playPreview() {
|
||||
stopPlayback(); stopPlayback = () => {};
|
||||
const button = $('#preview-motion');
|
||||
button.setAttribute('aria-pressed', String(previewPlayback));
|
||||
button.textContent = previewPlayback ? 'Bewegung stoppen' : 'Bewegung testen';
|
||||
const doc = $('#preview').contentDocument;
|
||||
if (previewPlayback && doc?.body) {
|
||||
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
|
||||
stopPlayback = () => { stopMotion(); stopSliders(); };
|
||||
}
|
||||
}
|
||||
|
||||
$('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); });
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
$('#message').textContent = message;
|
||||
@@ -56,7 +74,7 @@ async function request(path, body, signal) {
|
||||
|
||||
function controls() {
|
||||
const ready = Boolean(history);
|
||||
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
|
||||
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
|
||||
$('#save').disabled = !ready || busy || conflict || !history.dirty;
|
||||
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
|
||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||
@@ -77,6 +95,7 @@ function controls() {
|
||||
$('#select-page').disabled = !ready || busy;
|
||||
$('#add-root-section').disabled = !ready || busy;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
$('#preview-motion').disabled = !ready || busy;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
@@ -440,7 +459,7 @@ function renderLegacyEffects(block, panel) {
|
||||
none: 'Keiner', lift: 'Leicht anheben', sink: 'Leicht absenken', zoom: 'Bild vergrößern',
|
||||
glow: 'Leuchten', tilt: 'Leicht kippen', 'grow-border': 'Rahmen einfahren',
|
||||
}],
|
||||
['entrance', 'Einblendung beim Laden', {
|
||||
['entrance', 'Einblendung beim Erscheinen', {
|
||||
none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten', sink: 'Von oben', left: 'Von links',
|
||||
right: 'Von rechts', zoom: 'Heranzoomen', flip: 'Kippen', blur: 'Aus der Unschärfe',
|
||||
}],
|
||||
@@ -453,7 +472,7 @@ function renderLegacyEffects(block, panel) {
|
||||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||||
panel.append(labelEl, select);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder. Einblendungen laufen beim Laden der Seite, nicht beim Scrollen: eine öffentliche Seite führt kein JavaScript aus.'; panel.append(note);
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
@@ -654,6 +673,7 @@ async function updatePreview() {
|
||||
highlight(revealAfterPreview === selected); revealAfterPreview = null;
|
||||
markInsertTarget();
|
||||
frame.style.visibility = '';
|
||||
playPreview();
|
||||
$('#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.';
|
||||
@@ -663,6 +683,7 @@ async function updatePreview() {
|
||||
};
|
||||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||||
frame.style.visibility = 'hidden';
|
||||
stopPlayback();
|
||||
frame.srcdoc = html;
|
||||
// Sobald der Kopf da ist, wird gestaltet - geprüft Bild für Bild statt auf ein Ereignis zu
|
||||
// warten, das es für srcdoc nicht zuverlässig gibt.
|
||||
@@ -696,7 +717,7 @@ function changed() {
|
||||
|
||||
async function loadDesignSchema() {
|
||||
try {
|
||||
const response = await fetch('/admin/api/design', { credentials: 'same-origin' });
|
||||
const response = await fetch(adminBase + '/api/design', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
designSchema = await response.json();
|
||||
designPanel = createDesignPanel({
|
||||
@@ -1024,7 +1045,7 @@ $('#confirm-preset').addEventListener('click', async () => {
|
||||
const name = $('#preset-name').value.trim();
|
||||
const error = $('#preset-error');
|
||||
try {
|
||||
const response = await fetch('/admin/api/style-presets', {
|
||||
const response = await fetch(adminBase + '/api/style-presets', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': designSchema.csrfToken },
|
||||
body: JSON.stringify({ name, style: pendingPreset }),
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
|
||||
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}}
|
||||
|
||||
|
||||
/* Entrance animations and hover effects, added 12.09.2026.
|
||||
|
||||
Every animation stays inside the reduced-motion guard: whoever asked their system for less
|
||||
movement gets the finished state, not a softened animation. They play on load, because a public
|
||||
page runs no script and there is nothing to observe a scroll position with - that is a
|
||||
limitation, not an oversight, and it is written in the editor. */
|
||||
/* Hover effects remain CSS; entrance effects are observed by motion.js. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.fx-entrance-sink { animation: kant-sink .55s ease-out backwards }
|
||||
.fx-entrance-left { animation: kant-left .55s ease-out backwards }
|
||||
.fx-entrance-right { animation: kant-right .55s ease-out backwards }
|
||||
.fx-entrance-zoom { animation: kant-zoom .55s ease-out backwards }
|
||||
.fx-entrance-flip { animation: kant-flip .7s ease-out backwards }
|
||||
.fx-entrance-blur { animation: kant-unblur .6s ease-out backwards }
|
||||
.fx-delay-short { animation-delay: .15s }
|
||||
.fx-delay-medium { animation-delay: .3s }
|
||||
.fx-delay-long { animation-delay: .6s }
|
||||
|
||||
.fx-hover-sink { transition: transform .2s ease }
|
||||
.fx-hover-sink:hover { transform: translateY(3px) }
|
||||
.fx-hover-glow { transition: box-shadow .2s ease }
|
||||
@@ -30,17 +15,11 @@
|
||||
.fx-hover-grow-border { transition: box-shadow .2s ease }
|
||||
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
|
||||
}
|
||||
@keyframes kant-sink { from { opacity: 0; transform: translateY(-18px) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-left { from { opacity: 0; transform: translateX(-24px) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-right { from { opacity: 0; transform: translateX(24px) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-zoom { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-flip { from { opacity: 0; transform: perspective(800px) rotateX(-12deg) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-unblur { from { opacity: 0; filter: blur(8px) } to { opacity: 1; filter: none } }
|
||||
|
||||
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
|
||||
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
|
||||
|
||||
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
|
||||
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
|
||||
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
|
||||
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
|
||||
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
|
||||
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@@ -66,43 +45,3 @@
|
||||
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
|
||||
.button:hover { transform: translateY(-2px) }
|
||||
}
|
||||
|
||||
/* Einblenden beim Hineinscrollen, ergänzt am 13.09.2026.
|
||||
|
||||
Bisher liefen die Einblendungen beim Laden der Seite - was unterhalb der Falz stand, war
|
||||
fertig animiert, bevor es jemand gesehen hat. Mit einer Scroll-Zeitachse läuft dieselbe
|
||||
Animation genau dann, wenn das Element in den Blick kommt, und zwar ohne eine Zeile
|
||||
JavaScript: `animation-timeline: view()` bindet den Ablauf an die Sichtbarkeit.
|
||||
|
||||
Drei Bedingungen, alle bewusst:
|
||||
- Hinter der Bewegungsschranke: wer weniger Bewegung eingestellt hat, bekommt keine.
|
||||
- Hinter `@supports`: Browser ohne Scroll-Zeitachse (heute Firefox und Safari) behalten die
|
||||
bisherige Fassung - die Animation läuft beim Laden. Niemand sieht deshalb weniger Inhalt.
|
||||
- `animation-range` endet früh (`cover 26%`): die Bewegung ist vorbei, bevor das Element in
|
||||
der Mitte des Bildschirms steht. Eine Einblendung, die beim Lesen noch läuft, stört. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
/* Eigene Kurven für den Scrollweg: **nur Bewegung, keine Deckkraft**.
|
||||
Eine Scroll-Zeitachse ist umkehrbar - ein Abschnitt außerhalb des Blicks steht wieder am
|
||||
Anfang. Alles unterhalb der Falz wäre also dauerhaft blass, und blasser Text ist nicht
|
||||
weniger Effekt, sondern schlechterer Kontrast: #3d4d5c bei 85 Prozent ergibt 3,86:1
|
||||
statt 7,1:1 - axe meldet das zu Recht. Der Weg trägt den Effekt allein. */
|
||||
@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }
|
||||
@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }
|
||||
|
||||
.fx-entrance-fade, .fx-entrance-rise, .fx-entrance-sink, .fx-entrance-left,
|
||||
.fx-entrance-right, .fx-entrance-zoom, .fx-entrance-flip, .fx-entrance-blur {
|
||||
animation-name: kant-scroll-rise;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 10% cover 28%;
|
||||
animation-duration: 1ms; /* Die Zeitachse bestimmt den Ablauf, nicht die Uhr. */
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.fx-entrance-fade { animation-name: kant-scroll-fade }
|
||||
/* Die Verzögerungsstufen sind auf einer Scroll-Zeitachse eine Verschiebung im Weg,
|
||||
nicht in der Zeit - sonst würden sie gar nicht wirken. */
|
||||
.fx-delay-short { animation-range: entry 16% cover 32% }
|
||||
.fx-delay-medium { animation-range: entry 22% cover 36% }
|
||||
.fx-delay-long { animation-range: entry 28% cover 40% }
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
$('#media-insert').disabled = state || !selected.size;
|
||||
};
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
|
||||
const response = await fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
|
||||
const form = document.querySelector('#menu-editor');
|
||||
const list = form.querySelector('#menu-items');
|
||||
const status = form.querySelector('#menu-status');
|
||||
const error = form.querySelector('#menu-error');
|
||||
const history = [];
|
||||
let serial = list.children.length, pendingPage = null, busy = false;
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
||||
const rows = () => [...list.children].map(row => {
|
||||
const data = { level: Number(row.dataset.level) };
|
||||
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.value; });
|
||||
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
|
||||
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
|
||||
return data;
|
||||
});
|
||||
const state = () => ({ name: form.elements.name.value, rows: rows() });
|
||||
const key = value => JSON.stringify(value);
|
||||
let last = state(), saved = key(last);
|
||||
const dirty = () => key(state()) !== saved;
|
||||
|
||||
function refresh() {
|
||||
[...list.children].forEach((row, index) => {
|
||||
row.classList.toggle('menu-child', row.dataset.level === '2');
|
||||
row.querySelectorAll('[data-key]').forEach(input => { input.name = `items[${index}][${input.dataset.key}]`; });
|
||||
row.querySelector('[data-key="position"]').value = index;
|
||||
row.querySelector('[data-key="level"]').value = row.dataset.level;
|
||||
const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`;
|
||||
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
|
||||
row.querySelector('.menu-entry-title').textContent = name;
|
||||
row.querySelector('[data-menu-action="up"]').disabled = index === 0;
|
||||
row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1;
|
||||
row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2';
|
||||
row.querySelector('[data-menu-action="outdent"]').disabled = row.dataset.level === '1';
|
||||
row.querySelector('[role="group"]').setAttribute('aria-label', `${name} verschieben oder entfernen`);
|
||||
row.querySelectorAll('button').forEach(button => { button.disabled ||= busy; });
|
||||
row.querySelector('[data-menu-action="up"]').setAttribute('aria-label', `${name} nach oben`);
|
||||
row.querySelector('[data-menu-action="down"]').setAttribute('aria-label', `${name} nach unten`);
|
||||
});
|
||||
form.querySelector('#menu-count').textContent = `${list.children.length} Einträge`;
|
||||
form.querySelector('#menu-empty').hidden = Boolean(list.children.length);
|
||||
form.querySelector('#menu-undo').disabled = !history.length || busy;
|
||||
status.textContent = busy ? 'Wird gespeichert …' : dirty() ? 'Ungespeicherte Änderungen' : 'Gespeicherter Stand';
|
||||
}
|
||||
|
||||
function createRow(data) {
|
||||
const id = `new-${serial++}`;
|
||||
const row = el('li', 'menu-entry'); row.dataset.level = String(data.level ?? 1);
|
||||
const field = (key, value, label, type = 'text') => {
|
||||
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
|
||||
input.id = `menu-${key}-${id}`;
|
||||
if (type !== 'hidden') { const caption = el('label', '', label); caption.htmlFor = input.id; body.append(caption, input); }
|
||||
else row.append(input);
|
||||
return input;
|
||||
};
|
||||
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
|
||||
const details = el('details'); const summary = el('summary');
|
||||
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'muted', data.type === 'page' ? 'Inhalt' : 'Eigener Link'));
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
|
||||
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
||||
if (data.type === 'page') {
|
||||
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
||||
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
|
||||
picker.querySelector('label').htmlFor = input.id; picker.querySelector('label').textContent = 'Verknüpfter Inhalt';
|
||||
const options = picker.querySelector('[role="listbox"]'); options.id = `${input.id}-results`; options.replaceChildren(); options.hidden = true;
|
||||
input.setAttribute('aria-controls', options.id); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant');
|
||||
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
|
||||
body.append(picker); setupPicker(picker);
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
||||
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
||||
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
||||
}
|
||||
row.append(actions); return row;
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
|
||||
}
|
||||
function commit(before) {
|
||||
refresh(); const after = state();
|
||||
if (key(before) !== key(after)) { history.push(before); if (history.length > 50) history.shift(); }
|
||||
last = after; refresh();
|
||||
}
|
||||
function add(data) {
|
||||
if (list.children.length >= 60) { error.textContent = 'Ein Menü fasst höchstens 60 Einträge.'; error.hidden = false; return; }
|
||||
const before = state(); const row = createRow(data); list.append(row); commit(before);
|
||||
row.scrollIntoView({ block: 'nearest' }); row.querySelector('summary').focus();
|
||||
}
|
||||
|
||||
const addPicker = form.querySelector('.menu-add .content-picker');
|
||||
addPicker.querySelector('.picker-value').addEventListener('change', () => {
|
||||
const id = addPicker.querySelector('.picker-value').value;
|
||||
pendingPage = id ? { id, display: addPicker.querySelector('[role="combobox"]').value } : null;
|
||||
form.querySelector('#menu-add-page').disabled = !pendingPage;
|
||||
});
|
||||
form.querySelector('#menu-add-page').addEventListener('click', () => {
|
||||
if (pendingPage) add({ type: 'page', label: pendingPage.display.replace(/ \(\/.*\)$/, ''), pageId: pendingPage.id, pageDisplay: pendingPage.display, level: 1 });
|
||||
});
|
||||
form.querySelector('#menu-add-url').addEventListener('click', () => {
|
||||
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
|
||||
if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; }
|
||||
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), level: 1 }); name.value = url.value = '';
|
||||
});
|
||||
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
|
||||
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
|
||||
list.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-menu-action]'); if (!button || busy) return;
|
||||
const row = button.closest('.menu-entry'), before = state(), action = button.dataset.menuAction;
|
||||
const entries = [...list.children], index = entries.indexOf(row);
|
||||
const bundle = [row];
|
||||
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
|
||||
if (action === 'remove') {
|
||||
row.remove();
|
||||
// Removing a heading never deletes its links: promote its children visibly.
|
||||
bundle.slice(1).forEach(child => { child.dataset.level = '1'; });
|
||||
}
|
||||
if (action === 'indent') row.dataset.level = '2';
|
||||
if (action === 'outdent') row.dataset.level = '1';
|
||||
if (action === 'up' && index > 0) {
|
||||
let target = index - 1;
|
||||
if (row.dataset.level === '1') while (target > 0 && entries[target].dataset.level === '2') target--;
|
||||
bundle.forEach(item => list.insertBefore(item, entries[target]));
|
||||
}
|
||||
if (action === 'down') {
|
||||
let target = index + bundle.length;
|
||||
if (entries[target]) {
|
||||
target++;
|
||||
if (row.dataset.level === '1') while (entries[target]?.dataset.level === '2') target++;
|
||||
bundle.forEach(item => list.insertBefore(item, entries[target] || null));
|
||||
}
|
||||
}
|
||||
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
|
||||
commit(before); if (row.isConnected) row.querySelector('summary').focus();
|
||||
});
|
||||
form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); });
|
||||
window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } });
|
||||
form.noValidate = true;
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (busy) return;
|
||||
const invalid = form.querySelector(':invalid');
|
||||
if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; }
|
||||
refresh(); const body = new FormData(form); const snapshot = key(state());
|
||||
busy = true; error.hidden = true; form.querySelectorAll('input,button').forEach(node => { node.disabled = true; }); refresh();
|
||||
try {
|
||||
const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
||||
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.');
|
||||
const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.');
|
||||
form.elements.expectedVersion.value = result.version; saved = snapshot;
|
||||
} catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; }
|
||||
finally { busy = false; form.querySelectorAll('input,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
|
||||
});
|
||||
refresh(); last = state(); saved = key(last);
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Visible content is the baseline. Enhance only when it enters the viewport. */
|
||||
export function initMotion(doc = document) {
|
||||
const view = doc.defaultView;
|
||||
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const animations = new Set();
|
||||
const seen = new WeakSet();
|
||||
const start = {
|
||||
fade: { opacity: .2 }, rise: { transform: 'translateY(28px)' }, sink: { transform: 'translateY(-28px)' },
|
||||
left: { transform: 'translateX(-36px)' }, right: { transform: 'translateX(36px)' },
|
||||
zoom: { transform: 'scale(.92)' }, flip: { transform: 'perspective(800px) rotateX(-16deg)' }, blur: { filter: 'blur(6px)' },
|
||||
};
|
||||
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(node => Object.keys(start).some(name => node.classList.contains(`fx-entrance-${name}`)));
|
||||
const show = node => {
|
||||
if (seen.has(node) || preference.matches) return;
|
||||
seen.add(node);
|
||||
const name = Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
|
||||
const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0;
|
||||
// Fade itself is optional; direction, zoom and flip keep text at full contrast.
|
||||
const style = view.getComputedStyle(node);
|
||||
const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
|
||||
const first = { ...start[name] };
|
||||
if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform;
|
||||
const animation = node.animate([first, finish], { duration: 650, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'none' });
|
||||
animations.add(animation); animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
|
||||
};
|
||||
const observer = new view.IntersectionObserver(entries => {
|
||||
for (const entry of entries) if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
|
||||
}, { threshold: 0, rootMargin: '0px 0px -24px 0px' });
|
||||
if (!preference.matches) nodes.forEach(node => observer.observe(node));
|
||||
const reduce = () => { if (preference.matches) { observer.disconnect(); animations.forEach(animation => animation.cancel()); } };
|
||||
preference.addEventListener('change', reduce);
|
||||
return () => { observer.disconnect(); animations.forEach(animation => animation.cancel()); preference.removeEventListener('change', reduce); };
|
||||
}
|
||||
|
||||
if (!document.querySelector('#editor')) initMotion();
|
||||
@@ -0,0 +1,67 @@
|
||||
export function setupPicker(picker, onSelect = () => {}) {
|
||||
if (picker.dataset.ready) return;
|
||||
picker.dataset.ready = 'true';
|
||||
const input = picker.querySelector('[role="combobox"]');
|
||||
const value = picker.querySelector('.picker-value');
|
||||
const results = picker.querySelector('[role="listbox"]');
|
||||
const status = picker.querySelector('.picker-status');
|
||||
let controller, timer, current = -1, items = [];
|
||||
const close = () => {
|
||||
results.hidden = true; input.setAttribute('aria-expanded', 'false');
|
||||
input.removeAttribute('aria-activedescendant'); current = -1;
|
||||
};
|
||||
const select = item => {
|
||||
value.value = item?.id ?? '';
|
||||
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
|
||||
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
|
||||
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
|
||||
};
|
||||
const activate = index => {
|
||||
current = Math.max(0, Math.min(items.length - 1, index));
|
||||
[...results.children].forEach((node, i) => node.setAttribute('aria-selected', String(i === current)));
|
||||
const option = results.children[current];
|
||||
if (option) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); }
|
||||
};
|
||||
const search = async query => {
|
||||
controller?.abort(); controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const url = new URL(picker.dataset.source, location.href);
|
||||
url.searchParams.set('q', query); url.searchParams.set('type', picker.dataset.type || '');
|
||||
url.searchParams.set('parents', picker.dataset.parents || '0');
|
||||
status.textContent = 'Wird gesucht …';
|
||||
try {
|
||||
const response = await fetch(url, { signal, credentials: 'same-origin' });
|
||||
if (!response.ok || response.redirected) throw new Error();
|
||||
const data = await response.json();
|
||||
if (signal.aborted) return;
|
||||
items = data.items; current = -1; results.replaceChildren();
|
||||
items.forEach((item, index) => {
|
||||
const option = document.createElement('button'); option.type = 'button'; option.tabIndex = -1;
|
||||
option.id = `${input.id}-option-${index}`; option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false');
|
||||
const title = document.createElement('strong'); title.textContent = item.title;
|
||||
const detail = document.createElement('span'); detail.textContent = `/${item.path === 'home' ? '' : item.path} · ${item.type === 'post' ? 'Beitrag' : 'Seite'}${item.published ? '' : ' · Entwurf'}`;
|
||||
option.append(title, detail); option.addEventListener('mousedown', event => event.preventDefault());
|
||||
option.addEventListener('click', () => { select(item); input.focus({ preventScroll: true }); close(); }); results.append(option);
|
||||
});
|
||||
results.hidden = !items.length; input.setAttribute('aria-expanded', String(Boolean(items.length)));
|
||||
status.textContent = items.length ? `${items.length} Treffer${items.length === 20 ? ' · Suche eingrenzen für weitere Ergebnisse' : ''}.` : 'Keine passenden Inhalte gefunden.';
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') { close(); status.textContent = 'Inhalte konnten nicht geladen werden. Bitte erneut suchen oder neu anmelden.'; }
|
||||
}
|
||||
};
|
||||
input.addEventListener('focus', () => search(''));
|
||||
input.addEventListener('input', () => {
|
||||
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
value.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); controller?.abort(); clearTimeout(timer); close(); }
|
||||
if (['ArrowDown', 'ArrowUp'].includes(event.key) && !results.hidden) { event.preventDefault(); activate(current + (event.key === 'ArrowDown' ? 1 : -1)); }
|
||||
if (event.key === 'Enter' && current >= 0 && !results.hidden) { event.preventDefault(); select(items[current]); }
|
||||
});
|
||||
picker.addEventListener('focusout', event => { if (!picker.contains(event.relatedTarget)) { controller?.abort(); clearTimeout(timer); close(); } });
|
||||
picker.querySelector('.picker-clear').addEventListener('click', () => { controller?.abort(); clearTimeout(timer); select(null); input.focus(); });
|
||||
}
|
||||
|
||||
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
@@ -197,7 +197,12 @@ body.consent-open{padding-bottom:260px}
|
||||
.slider-snap>.slider-slide{scroll-snap-align:start}
|
||||
.slider-slide{min-width:0}
|
||||
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
|
||||
.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}
|
||||
.slider-dots{position:relative}
|
||||
@supports selector(::scroll-marker){.slider-dots:not(.slider-controlled){scroll-marker-group:after;scroll-behavior:smooth;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots:not(.slider-controlled)::-webkit-scrollbar{display:none}.slider-dots:not(.slider-controlled)>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}}
|
||||
.slider-controlled{scrollbar-width:none;scrollbar-gutter:auto}.slider-controlled::-webkit-scrollbar{display:none}
|
||||
.slider-controls{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;min-height:48px;margin-top:8px;padding:4px;font:14px/1.4 system-ui,sans-serif}.slider-controls button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:40px;padding:8px 12px;border:1px solid #b2c2b8;border-radius:7px;background:#fff;color:#193c2d;cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:#e4efe7}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:30px;padding:5px;border:0;background:transparent;color:inherit;opacity:.5}.slider-control-dots button[aria-pressed=true]{opacity:1;outline:1px solid currentColor;outline-offset:-5px}.slider-controls[hidden]{display:none}
|
||||
@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}}
|
||||
|
||||
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
|
||||
+78
-64
@@ -1,67 +1,81 @@
|
||||
/**
|
||||
* Lets a slider advance by itself - and stop as soon as somebody takes over.
|
||||
*
|
||||
* Without this script a slider is a scroll container with snap points and, where the browser can
|
||||
* draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic
|
||||
* step, and it is loaded exclusively on pages that carry a slider which asked for it.
|
||||
*
|
||||
* What it refuses to do:
|
||||
* - it does not run when the system asks for reduced motion,
|
||||
* - it stops for good at the first touch, click, key press or manual scroll,
|
||||
* - it pauses while the pointer is inside, while something in it has focus, and while the tab is
|
||||
* in the background or the slider is off screen.
|
||||
*
|
||||
* A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be
|
||||
* stopped is a barrier.
|
||||
*/
|
||||
(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const setup = slider => {
|
||||
const seconds = Number(slider.dataset.autoplay);
|
||||
if (!Number.isFinite(seconds) || seconds < 3) return;
|
||||
|
||||
let paused = false;
|
||||
let stopped = false;
|
||||
let visible = true;
|
||||
let ownScroll = 0;
|
||||
|
||||
const slides = [...slider.children];
|
||||
if (slides.length < 2) return;
|
||||
|
||||
const step = () => {
|
||||
if (stopped || paused || !visible || document.hidden) return;
|
||||
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
|
||||
// Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or
|
||||
// something further up decides the number, and getting that wrong means scrolling nowhere.
|
||||
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
|
||||
const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2);
|
||||
if (!next) return;
|
||||
// Remember that the next scroll event is ours, so it does not look like a person.
|
||||
ownScroll = Date.now();
|
||||
slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' });
|
||||
/** Scroll snapping is the baseline; arrows, dots and controllable playback enhance it. */
|
||||
export function initSliders(doc = document) {
|
||||
const view = doc.defaultView;
|
||||
const cleanups = [];
|
||||
doc.querySelectorAll('.block-slider').forEach(slider => {
|
||||
const slides = [...slider.querySelectorAll(':scope > .slider-slide')];
|
||||
if (slides.length < 2 || slider.dataset.enhanced) return;
|
||||
slider.dataset.enhanced = 'true';
|
||||
const events = new view.AbortController(), signal = events.signal;
|
||||
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const seconds = Number(slider.dataset.autoplay || 0);
|
||||
let playing = seconds >= 3 && !preference.matches, hover = false, visible = false, timer;
|
||||
const controls = doc.createElement('div'); controls.className = 'slider-controls';
|
||||
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
|
||||
const button = (text, label, action) => {
|
||||
const node = doc.createElement('button'); node.type = 'button'; node.textContent = text;
|
||||
node.setAttribute('aria-label', label); node.setAttribute('aria-controls', slider.id);
|
||||
node.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); action(); }, { signal });
|
||||
controls.append(node); return node;
|
||||
};
|
||||
|
||||
const stop = () => { stopped = true; clearInterval(timer); };
|
||||
const timer = setInterval(step, seconds * 1000);
|
||||
|
||||
slider.addEventListener('pointerenter', () => { paused = true; });
|
||||
slider.addEventListener('pointerleave', () => { paused = false; });
|
||||
slider.addEventListener('focusin', () => { paused = true; });
|
||||
slider.addEventListener('focusout', () => { paused = false; });
|
||||
for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) {
|
||||
slider.addEventListener(event, stop, { passive: true });
|
||||
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
|
||||
const active = () => {
|
||||
let index = 0, distance = Infinity;
|
||||
slides.forEach((slide, i) => { const delta = Math.abs(left(slide) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
|
||||
return index;
|
||||
};
|
||||
const go = index => {
|
||||
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: preference.matches ? 'instant' : 'smooth' });
|
||||
};
|
||||
const step = direction => {
|
||||
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
|
||||
const first = slider.scrollLeft <= 2;
|
||||
go(direction > 0 ? (last ? 0 : Math.min(slides.length - 1, active() + 1)) : (first ? slides.length - 1 : Math.max(0, active() - 1)));
|
||||
};
|
||||
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
|
||||
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1)));
|
||||
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
|
||||
const next = button('→', 'Nächste Folie', () => manual(() => step(1)));
|
||||
const dots = [];
|
||||
if (slider.classList.contains('slider-dots')) {
|
||||
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
|
||||
slides.forEach((slide, i) => {
|
||||
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
|
||||
});
|
||||
}
|
||||
slider.addEventListener('scroll', () => {
|
||||
// A scroll that is not the one we just started belongs to a person; then we are done.
|
||||
if (Date.now() - ownScroll > 1200) stop();
|
||||
}, { passive: true });
|
||||
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
|
||||
const update = () => {
|
||||
const fits = slider.scrollWidth <= slider.clientWidth + 2;
|
||||
controls.hidden = fits; previous.disabled = next.disabled = fits;
|
||||
const index = active(); count.textContent = `${index + 1} / ${slides.length}`;
|
||||
dots.forEach((dot, i) => dot.setAttribute('aria-pressed', String(i === index)));
|
||||
if (play) {
|
||||
play.textContent = playing ? 'Pause' : 'Wiedergabe';
|
||||
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
|
||||
play.disabled = preference.matches; play.title = preference.matches ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
clearTimeout(timer);
|
||||
if (playing && !preference.matches) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
|
||||
};
|
||||
const pause = () => { playing = false; update(); clearTimeout(timer); };
|
||||
slider.after(controls); slider.classList.add('slider-controlled'); update();
|
||||
slider.addEventListener('scroll', update, { passive: true, signal });
|
||||
slider.addEventListener('pointerenter', () => { hover = true; }, { signal });
|
||||
slider.addEventListener('pointerleave', () => { hover = false; schedule(); }, { signal });
|
||||
for (const event of ['pointerdown', 'wheel', 'touchstart', 'focusin']) slider.addEventListener(event, pause, { passive: true, signal });
|
||||
slider.addEventListener('keydown', event => {
|
||||
if (event.target !== slider || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||
event.preventDefault(); manual(() => step(event.key === 'ArrowRight' ? 1 : -1));
|
||||
}, { signal });
|
||||
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider);
|
||||
const resize = new view.ResizeObserver(update); resize.observe(slider);
|
||||
preference.addEventListener('change', () => { if (preference.matches) pause(); update(); }, { signal });
|
||||
schedule();
|
||||
cleanups.push(() => { clearTimeout(timer); events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
||||
});
|
||||
return () => cleanups.forEach(cleanup => cleanup());
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 })
|
||||
.observe(slider);
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup);
|
||||
})();
|
||||
if (!document.querySelector('#editor')) initSliders();
|
||||
Reference in new issue
Block a user