Files
Flaro/web/app/src/menue.ts
T
ScarriffleandClaude Opus 5 557708b769 Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren"
lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine
Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als
Meldung statt in der Browserkonsole.

Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten
neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues
Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt,
endet nicht mehr in einer Sackgasse.

Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit
404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die
Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in
einer Prüfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:28:33 +02:00

155 lines
5.8 KiB
TypeScript

import { icons } from './icons';
import { hinweisZeigen, meldung } from './util';
export type Menuepunkt = {
text: string;
icon?: keyof typeof icons;
/** Rot und unten abgesetzt — für alles, was etwas wegnimmt. */
gefaehrlich?: boolean;
tun: () => void | Promise<void>;
};
/** Ein Kontextmenü auf einer Kachel.
*
* **Warum es das braucht.** Gesehen, Favorit und Weglegen gab es
* ausschliesslich auf der Detailseite. In einer Übersicht mit zwanzig Titeln
* hiess „die hab ich alle gesehen" vierzig Seitenwechsel.
*
* **Drei Wege hinein, weil es drei Arten gibt zu bedienen.** Rechtsklick für
* die Maus, `Shift+F10` und die Kontextmenü-Taste für die Tastatur, langer
* Druck für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf
* dem Telefon zu verstecken.
*/
export function kontextmenue(ziel: HTMLElement, punkte: () => Menuepunkt[]): void {
ziel.addEventListener('contextmenu', e => {
e.preventDefault();
oeffnen(e.clientX, e.clientY, punkte(), ziel);
});
// Die Kontextmenü-Taste und Shift+F10 — dieselbe Geste ohne Maus.
ziel.addEventListener('keydown', e => {
if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
e.preventDefault();
const kasten = ziel.getBoundingClientRect();
oeffnen(kasten.left + kasten.width / 2, kasten.top + kasten.height / 2, punkte(), ziel);
});
// Langer Druck. 500 ms, und jede Bewegung bricht ab — sonst löst Scrollen
// das Menü aus, und das Scrollen hört dabei auf.
let druck: number | undefined;
let start = { x: 0, y: 0 };
ziel.addEventListener('touchstart', e => {
const f = e.touches[0];
if (!f) return;
start = { x: f.clientX, y: f.clientY };
druck = window.setTimeout(() => oeffnen(start.x, start.y, punkte(), ziel), 500);
}, { passive: true });
const abbrechen = () => { if (druck !== undefined) { clearTimeout(druck); druck = undefined; } };
ziel.addEventListener('touchend', abbrechen);
ziel.addEventListener('touchcancel', abbrechen);
ziel.addEventListener('touchmove', e => {
const f = e.touches[0];
if (!f) return;
if (Math.abs(f.clientX - start.x) > 8 || Math.abs(f.clientY - start.y) > 8) abbrechen();
}, { passive: true });
}
/** Öffnet das Menü an einem Element — für den sichtbaren Griff.
*
* **Am Element und nicht am Mauszeiger.** Wer die drei Punkte mit der
* Tastatur auslöst, hat keinen Zeiger; das Menü soll trotzdem dort erscheinen,
* wo der Griff ist.
*/
export function menueAnZiel(ziel: HTMLElement, punkte: Menuepunkt[]): void {
const kasten = ziel.getBoundingClientRect();
oeffnen(kasten.right, kasten.bottom + 4, punkte, ziel);
}
/** Das offene Menü — immer höchstens eines. */
let offen: HTMLElement | null = null;
/** Woran es hängt — der Knopf, der es geöffnet hat.
*
* **Weil der öffnende Klick noch unterwegs ist.** Er blubbert nach dem
* eigenen Zuhörer weiter zum Dokument, und der dortige schliesst alles, was
* nicht im Menü liegt — also auch dieses, im selben Augenblick. In der
* Mediathek stand deshalb ein `stopPropagation()` am Knopf; in der Prüfliste
* fehlte es, und das Menü ging gar nicht auf. Eine Regel, an die sich jeder
* Aufrufer erinnern muss, ist keine Regel. Jetzt merkt sich das Menü selbst,
* woran es hängt.
*/
let ausloeser: HTMLElement | null = null;
export function menueSchliessen(): void {
offen?.remove();
offen = null;
ausloeser = null;
}
function oeffnen(
x: number, y: number, punkte: Menuepunkt[], woran: HTMLElement | null = null,
): void {
menueSchliessen();
if (punkte.length === 0) return;
ausloeser = woran;
const menue = document.createElement('div');
menue.className = 'kontextmenue';
menue.setAttribute('role', 'menu');
punkte.forEach(p => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'kontextpunkt' + (p.gefaehrlich ? ' gefahr' : '');
b.setAttribute('role', 'menuitem');
if (p.icon) {
const i = document.createElement('span');
i.className = 'icon';
i.innerHTML = icons[p.icon];
b.appendChild(i);
}
const t = document.createElement('span');
t.textContent = p.text;
b.appendChild(t);
// **Ein Fehler darf nicht verschwinden.** Vorher stand hier
// `await p.tun()` ohne Auffangnetz: wirft die Aktion — und eine abgelehnte
// Anfrage tut das —, wird daraus eine unbehandelte Zusage. Die landet in
// der Konsole des Browsers und sonst nirgends. Für den Benutzer sah es
// aus, als täte der Klick einfach nichts.
b.onclick = async () => {
menueSchliessen();
try { await p.tun(); }
catch (fehler) { hinweisZeigen(meldung(fehler), 'fehler'); }
};
menue.appendChild(b);
});
document.body.appendChild(menue);
offen = menue;
// **Erst anhängen, dann messen.** Vorher hat das Menü keine Grösse, und
// die Prüfung auf den Bildschirmrand liefe gegen null.
const kasten = menue.getBoundingClientRect();
const links = Math.min(x, window.innerWidth - kasten.width - 8);
const oben = Math.min(y, window.innerHeight - kasten.height - 8);
menue.style.left = `${Math.max(8, links)}px`;
menue.style.top = `${Math.max(8, oben)}px`;
menue.querySelector<HTMLElement>('button')?.focus();
}
// Ein Klick daneben, Escape oder ein Bildlauf schliessen. Einmal global
// registriert und nicht je Kachel — sonst hingen an vierhundert Kacheln
// zwölfhundert Zuhörer am Dokument.
document.addEventListener('click', e => {
if (!offen) return;
const ziel = e.target as Node;
// Der Klick auf den Griff hat es gerade geöffnet — er schliesst es nicht.
if (offen.contains(ziel) || ausloeser?.contains(ziel)) return;
menueSchliessen();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') menueSchliessen(); });
window.addEventListener('scroll', menueSchliessen, { passive: true });