diff --git a/changelog.json b/changelog.json index 708834e..484c309 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "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.", "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": [ "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.", "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.", - "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." ] }, { diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index b19a4f6..f3fe35c 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -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 { 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.) export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) { const { t } = useI18n(); + const boxRef = useRef(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(() => { if (!menu) return; const close = () => onClose(); @@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ }; }, [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; 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 (
e.stopPropagation()} >