fix: right-click menu cut off at the bottom of the window

Closes #9.

It was clamped against a GUESSED height — 230 px with the upload submenu, 110
without. The menu is far taller than that with a selection, so right-clicking on
a row near the bottom hid half the entries, delete among them.

It is measured now: placed above the cursor when it does not fit below, pinned
to the top with its own scrollbar when it fits neither way. Its height depends
on which actions the caller passes, so no constant could have stayed right.
This commit is contained in:
2026-07-31 12:25:37 +02:00
parent c5ac945de0
commit e4217da010
2 changed files with 40 additions and 8 deletions
+4 -2
View File
@@ -9,7 +9,8 @@
"The play-on-air button is hidden on CW.", "The play-on-air button is hidden on CW.",
"CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.", "CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.",
"FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.", "FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.",
"Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards." "Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.",
"The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below."
], ],
"fr": [ "fr": [
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.", "FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
@@ -18,7 +19,8 @@
"Le bouton d'émission de l'enregistrement est masqué en CW.", "Le bouton d'émission de l'enregistrement est masqué en CW.",
"Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.", "Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.",
"FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.", "FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.",
"Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite." "Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.",
"Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous."
] ]
}, },
{ {
+36 -6
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react'; import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
// which used to dismiss the menu the instant it appeared.) // which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) { export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const boxRef = useRef<HTMLDivElement>(null);
// Starts at the cursor; the layout effect corrects it once the real size is
// known. Rendering at the cursor first avoids a visible jump for the common
// case where it already fits.
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
const close = () => onClose(); const close = () => onClose();
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
}; };
}, [menu, onClose]); }, [menu, onClose]);
// Position AFTER measuring.
//
// The menu used to be clamped against a guessed height — 230 px when the
// upload submenu was present, 110 otherwise. It is far taller than that with
// a selection, so right-clicking near the bottom of the window cut half the
// entries off. Its height also depends on which actions the caller passes, so
// no constant can be right for long.
//
// Measured instead: if it does not fit below the cursor it is placed above,
// and failing that pinned to the top with its own scrollbar.
useLayoutEffect(() => {
if (!menu || !boxRef.current) return;
const r = boxRef.current.getBoundingClientRect();
const pad = 6;
let x = menu.x;
let y = menu.y;
if (x + r.width > window.innerWidth - pad) {
x = Math.max(pad, window.innerWidth - r.width - pad);
}
if (y + r.height > window.innerHeight - pad) {
// Above the cursor, if there is room there.
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
}
setPos({ x, y });
}, [menu, onSendTo]);
if (!menu) return null; if (!menu) return null;
const n = menu.ids.length; const n = menu.ids.length;
// Keep the menu on-screen near the cursor.
const x = Math.min(menu.x, window.innerWidth - 248);
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
return ( return (
<div <div
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm" ref={boxRef}
style={{ left: x, top: y }} className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: pos.x, top: pos.y }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground"> <div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">