From e4217da01052a343c4b85069b3044beaaead66c1 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 31 Jul 2026 12:25:09 +0200 Subject: [PATCH] fix: right-click menu cut off at the bottom of the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.json | 6 ++-- frontend/src/components/QSOContextMenu.tsx | 42 ++++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) 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()} >