diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index d0d0630..6603dcf 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -23,6 +23,11 @@ export const icons = { /** Winkel nach oben — eine Ebene zurück im Baum. */ hoch: rahmen(''), runter: rahmen(''), + // Sechs Punkte — das übliche Zeichen für „hier anfassen". Gefüllt, + // weil Kreise mit Radius 1 als Umriss nur ein Fleck wären. + griff: rahmen('' + + '' + + '', true), /** Filmstreifen — für eine Filmmediathek. */ filme: rahmen('' + ''), diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 94ac729..5326499 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -147,8 +147,16 @@ function leisteBauen(): void { // die Entscheidung zurück, sie zu trennen. if (mediatheken.length) { gruppe('Mediatheken', istAdmin() && mediatheken.length > 1); + + // **Ein eigener Behälter für die Mediatheken.** Beim Ziehen wird gerechnet, + // die wievielte Zeile unter dem Zeiger liegt; stünden Überschriften und + // „Start" dazwischen, ginge diese Rechnung schief. + const liste = document.createElement('div'); + liste.className = 'nav-liste'; + leiste.appendChild(liste); + mediatheken.forEach((b, i) => eintrag( - b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i)); + b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i, liste)); } if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); } @@ -184,7 +192,7 @@ function leisteBauen(): void { function eintrag( text: string, ziel: string, bild: keyof typeof icons, - anzahl?: number | null, platz?: number, + anzahl?: number | null, platz?: number, behaelter?: HTMLElement, ): void { const b = document.createElement('button'); b.dataset.ziel = ziel; @@ -192,43 +200,112 @@ function leisteBauen(): void { + `${esc(text)}` + (anzahl != null && !leisteBearbeiten ? `${anzahl}` : ''); - // Im Bearbeitungsmodus führt die Zeile nirgendwohin — sonst wechselt man - // beim Zielen auf einen Pfeil versehentlich die Ansicht. - if (leisteBearbeiten && platz != null) { - b.classList.add('verschiebbar'); - b.appendChild(pfeil('hoch', platz, platz - 1, platz === 0, text)); - b.appendChild(pfeil('runter', platz, platz + 1, platz === mediatheken.length - 1, text)); + if (leisteBearbeiten && platz != null && behaelter) { + b.classList.add('ziehbar'); + + // Der Griff links statt eines Knopfes rechts: er sagt „hier anfassen", + // und er nimmt die Zeile nicht als Ganzes in Beschlag. + const griff = document.createElement('span'); + griff.className = 'nav-griff icon'; + griff.innerHTML = icons.griff; + b.prepend(griff); + + // **Die Tastatur bleibt bedienbar.** Ziehen geht damit nicht, und eine + // Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine + // benutzt, gar keine. Sichtbare Pfeile braucht es dafür nicht. + b.setAttribute('aria-roledescription', 'Verschiebbarer Eintrag'); + b.title = `„${text}" ziehen — oder mit den Pfeiltasten verschieben`; + b.onkeydown = e => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + void schieben(platz, platz + (e.key === 'ArrowDown' ? 1 : -1)); + }; + + ziehbarMachen(b, platz, behaelter); } else { b.onclick = () => { location.hash = ziel; }; } - leiste.appendChild(b); + (behaelter ?? leiste).appendChild(b); } +} - /** Ein Pfeil zum Schieben. - * - * **Zwei Knöpfe statt Ziehen und Ablegen.** Ziehen ist schöner, aber mit - * der Tastatur unbedienbar und auf dem Telefon ein Glücksspiel. - */ - function pfeil( - richtung: 'hoch' | 'runter', von: number, nach: number, gesperrt: boolean, name: string, - ): HTMLElement { - const p = document.createElement('span'); - p.className = 'nav-pfeil' + (gesperrt ? ' gesperrt' : ''); - p.setAttribute('role', 'button'); - if (!gesperrt) p.setAttribute('tabindex', '0'); - p.setAttribute('aria-label', `„${name}" nach ${richtung === 'hoch' ? 'oben' : 'unten'}`); - p.innerHTML = icons.hoch; - if (richtung === 'runter') p.style.transform = 'rotate(180deg)'; +/** Macht eine Zeile ziehbar. + * + * **Pointer Events statt HTML5-Drag.** Letzteres kennt kein Telefon, zeichnet + * ein Geisterbild, das sich nicht gestalten lässt, und liefert während des + * Ziehens keine brauchbaren Koordinaten. Pointer Events bedienen Maus, Finger + * und Stift mit demselben Code. + * + * **Mit Schwelle.** Erst nach sechs Bildpunkten gilt es als Ziehen — sonst + * verschiebt jeder leicht wackelige Klick die Reihenfolge. + */ +function ziehbarMachen(zeile: HTMLElement, platz: number, behaelter: HTMLElement): void { + zeile.addEventListener('pointerdown', start); - const los = (e: Event) => { - e.stopPropagation(); - if (gesperrt) return; - void schieben(von, nach); + function start(e: PointerEvent): void { + // Nur die linke Maustaste; rechts gehört dem Kontextmenü. + if (e.button !== 0) return; + e.preventDefault(); + + const zeilen = [...behaelter.querySelectorAll('.ziehbar')]; + const hoehe = zeile.getBoundingClientRect().height + + parseFloat(getComputedStyle(behaelter).rowGap || '0'); + const startY = e.clientY; + + let zieht = false; + let ziel = platz; + + zeile.setPointerCapture(e.pointerId); + + const bewegen = (m: PointerEvent) => { + const weg = m.clientY - startY; + + if (!zieht) { + if (Math.abs(weg) < 6) return; + zieht = true; + behaelter.classList.add('zieht'); + zeile.classList.add('wird-gezogen'); + } + + // Wohin die Zeile fiele, wenn man jetzt losliesse. + ziel = Math.max(0, Math.min(zeilen.length - 1, platz + Math.round(weg / hoehe))); + + zeile.style.transform = `translateY(${weg}px)`; + + // Die anderen machen Platz — nach oben oder unten, je nachdem, auf + // welcher Seite sie liegen. + zeilen.forEach((andere, i) => { + if (andere === zeile) return; + let versatz = 0; + if (platz < ziel && i > platz && i <= ziel) versatz = -hoehe; + else if (platz > ziel && i >= ziel && i < platz) versatz = hoehe; + andere.style.transform = versatz ? `translateY(${versatz}px)` : ''; + }); }; - p.onclick = los; - p.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); los(e); } }; - return p; + + const beenden = () => { + zeile.removeEventListener('pointermove', bewegen); + zeile.removeEventListener('pointerup', beenden); + zeile.removeEventListener('pointercancel', beenden); + + behaelter.classList.remove('zieht'); + zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('wird-gezogen'); }); + + // Ohne Ziehen war es ein Klick — und der führt zur Mediathek, auch im + // Bearbeitungsmodus. Sonst wäre die Leiste dort unbenutzbar. + if (!zieht) { + const weg = zeile.dataset.ziel; + if (weg) location.hash = weg; + return; + } + + if (ziel !== platz) void schieben(platz, ziel); + }; + + zeile.addEventListener('pointermove', bewegen); + zeile.addEventListener('pointerup', beenden); + zeile.addEventListener('pointercancel', beenden); } } diff --git a/web/app/src/style.css b/web/app/src/style.css index 294606f..088af21 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -181,16 +181,45 @@ svg { width: 20px; height: 20px; display: block; flex: none; } .nav-bearbeiten.an { color: var(--accent); } .nav-bearbeiten svg { width: 15px; height: 15px; display: block; } -/* Im Bearbeitungsmodus trägt die Zeile zwei Pfeile statt der Zahl. */ -#chrome button.verschiebbar { cursor: default; } -.nav-pfeil { - display: inline-flex; align-items: center; justify-content: center; - width: 20px; height: 20px; flex: none; - border-radius: var(--r-knopf); color: var(--text-muted); +/* ---------- Umsortieren durch Ziehen ---------- + * + * Der Behälter braucht keine eigene Form; er existiert, damit beim Ziehen + * gerechnet werden kann, die wievielte Zeile unter dem Zeiger liegt. + */ +.nav-liste { display: flex; flex-direction: column; } + +#chrome button.ziehbar { + cursor: grab; + /* Ohne das übernimmt der Browser die senkrechte Geste und scrollt, statt + * die Zeile zu bewegen — auf dem Telefon wäre Ziehen damit unmöglich. */ + touch-action: none; + user-select: none; + position: relative; +} +#chrome button.ziehbar:active { cursor: grabbing; } + +.nav-griff { + display: inline-flex; color: var(--text-faint); flex: none; margin-right: -2px; +} +.nav-griff svg { width: 14px; height: 14px; } +#chrome button.ziehbar:hover .nav-griff { color: var(--text-muted); } + +/* Die gezogene Zeile liegt über den anderen und wirft einen Schatten — + * sonst sieht man nicht, dass sie abgehoben ist. */ +#chrome button.wird-gezogen { + z-index: 3; cursor: grabbing; + background: var(--elevated); box-shadow: var(--schatten-dialog); + opacity: .95; +} + +/* Die weichenden Zeilen gleiten, die gezogene folgt dem Zeiger ohne + * Verzögerung — eine Übergangszeit auf ihr machte sie zäh. */ +#chrome .nav-liste.zieht button:not(.wird-gezogen) { + transition: transform .14s ease; +} +@media (prefers-reduced-motion: reduce) { + #chrome .nav-liste.zieht button:not(.wird-gezogen) { transition: none; } } -.nav-pfeil:hover { background: var(--raised); color: var(--text); } -.nav-pfeil.gesperrt { opacity: .25; pointer-events: none; } -.nav-pfeil svg { width: 14px; height: 14px; } #chrome button { display: flex; align-items: center; gap: var(--s-regular);