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; }; /** 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('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 });