From a8bb98c44c79548d70efd1a66677a12a7c8f52d7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 4 Oct 2026 17:09:28 +0200 Subject: [PATCH] Umsortiert wird gezogen, nicht geklickt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Pfeile sind weg; eine Mediathek wird in der Seitenleiste an ihren Platz gezogen. Mit 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. Mit Schwelle von sechs Bildpunkten — sonst verschiebt jeder leicht wackelige Klick die Reihenfolge, und ein Klick ohne Bewegung führt weiterhin zur Mediathek, auch im Bearbeitungsmodus. Die weichenden Zeilen gleiten, die gezogene folgt dem Zeiger ohne Verzögerung; bei „Bewegung reduzieren" gleitet nichts. Die Pfeiltasten bleiben auf der fokussierten Zeile, nur ohne sichtbare Knöpfe. Ziehen ist mit der Tastatur nicht bedienbar, und eine Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine benutzt, gar keine. Co-Authored-By: Claude Opus 5 --- web/app/src/icons.ts | 5 ++ web/app/src/main.ts | 139 ++++++++++++++++++++++++++++++++---------- web/app/src/style.css | 47 +++++++++++--- 3 files changed, 151 insertions(+), 40 deletions(-) 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);