fix(ui): stop Preferences redrawing with the main window; unclip the dropdowns
TWO FAULTS, ONE SYMPTOM — 'it refreshes ten times a second and the buttons cannot be pressed'. Preferences is a child of the main view, so every cluster spot, CAT push and decode re-rendered the entire panel. On a busy evening that is several times a second, and the panel is large enough that the rebuild outlasts the gap between them: buttons missed their clicks because the element under the pointer was replaced between the press and the release. It is memoised now, and the callbacks App hands it hold their identity — without that the memo compares unequal every time and buys nothing. The dropdown menu was an absolutely-positioned child, so it was clipped by whichever scrolling or overflow-hidden box it sat in: the satellite list showed one entry of eight. It is portalled to the body now, positioned from the field's rectangle, re-measured while open, and opens upward when the field is near the bottom of the screen — which is exactly where these fields tend to be.
This commit is contained in:
+6
-2
@@ -14,7 +14,9 @@
|
|||||||
"An editable list of satellites (Preferences → Lists → Satellites). The satellite-name field on the entry form offers them as a dropdown, alphabetically, and still accepts anything typed. SAT_NAME is compared character for character by the awards and by LoTW — AO-91 and AO91 are two different satellites to everything downstream — so a remembered spelling beats one retyped on every pass.",
|
"An editable list of satellites (Preferences → Lists → Satellites). The satellite-name field on the entry form offers them as a dropdown, alphabetically, and still accepts anything typed. SAT_NAME is compared character for character by the awards and by LoTW — AO-91 and AO91 are two different satellites to everything downstream — so a remembered spelling beats one retyped on every pass.",
|
||||||
"Awards, RDA district comparison: the conflict list is taller and scrolls, and a callsign in it opens the contact. It was capped at about six visible rows of a list that holds up to two hundred, inside a panel that would not scroll to show the rest — and nothing in it could be acted on.",
|
"Awards, RDA district comparison: the conflict list is taller and scrolls, and a callsign in it opens the contact. It was capped at about six visible rows of a list that holds up to two hundred, inside a panel that would not scroll to show the rest — and nothing in it could be acted on.",
|
||||||
"Awards, RDA: the compare and fill-districts buttons say they are working, and the comparison reports its own result — including 'no disagreement' and 'no Russian contacts', which are results and were previously indistinguishable from a button that did nothing. A failed comparison also used to print its error beside the OTHER button.",
|
"Awards, RDA: the compare and fill-districts buttons say they are working, and the comparison reports its own result — including 'no disagreement' and 'no Russian contacts', which are results and were previously indistinguishable from a button that did nothing. A failed comparison also used to print its error beside the OTHER button.",
|
||||||
"The satellite dropdown now follows the list as it is edited: the field read it once when the panel first appeared, so a list saved afterwards only showed up after a restart."
|
"The satellite dropdown now follows the list as it is edited: the field read it once when the panel first appeared, so a list saved afterwards only showed up after a restart.",
|
||||||
|
"Preferences no longer redraw with the main window. Being a child of the main view, the whole panel was rebuilt on every cluster spot and every CAT update — several times a second on a busy evening — which looked like a page refreshing constantly and made buttons miss their clicks, the element under the pointer being replaced between the press and the release.",
|
||||||
|
"Dropdown lists open over whatever is beneath them instead of being cut off by it. The satellite list showed one entry of eight, clipped by the panel it sat in."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Console Elecraft : les mesures d'émission sont lues dès que la RADIO se déclare en émission, et plus seulement quand OpsLog l'a mise en émission. Passer en émission par le PTT de façade, une pédale ou le bouton du micro laissait le panneau croire à une réception — et comme les barres de puissance et de ROS ne sont lues qu'en émission, elles ne l'étaient jamais pour qui manipule à la main.",
|
"Console Elecraft : les mesures d'émission sont lues dès que la RADIO se déclare en émission, et plus seulement quand OpsLog l'a mise en émission. Passer en émission par le PTT de façade, une pédale ou le bouton du micro laissait le panneau croire à une réception — et comme les barres de puissance et de ROS ne sont lues qu'en émission, elles ne l'étaient jamais pour qui manipule à la main.",
|
||||||
@@ -28,7 +30,9 @@
|
|||||||
"Une liste de satellites éditable (Préférences → Listes → Satellites). Le champ du nom de satellite dans la saisie les propose en liste déroulante, par ordre alphabétique, et accepte toujours ce qu'on tape. SAT_NAME est comparé caractère par caractère par les diplômes et par LoTW — AO-91 et AO91 sont deux satellites différents pour tout ce qui suit — donc une orthographe mémorisée vaut mieux qu'une ressaisie à chaque passage.",
|
"Une liste de satellites éditable (Préférences → Listes → Satellites). Le champ du nom de satellite dans la saisie les propose en liste déroulante, par ordre alphabétique, et accepte toujours ce qu'on tape. SAT_NAME est comparé caractère par caractère par les diplômes et par LoTW — AO-91 et AO91 sont deux satellites différents pour tout ce qui suit — donc une orthographe mémorisée vaut mieux qu'une ressaisie à chaque passage.",
|
||||||
"Diplômes, comparaison des districts RDA : la liste des divergences est plus haute et défile, et un indicatif y ouvre le contact. Elle était limitée à six lignes visibles environ pour une liste pouvant en contenir deux cents, dans un panneau qui ne défilait pas pour montrer le reste — et rien n'y était actionnable.",
|
"Diplômes, comparaison des districts RDA : la liste des divergences est plus haute et défile, et un indicatif y ouvre le contact. Elle était limitée à six lignes visibles environ pour une liste pouvant en contenir deux cents, dans un panneau qui ne défilait pas pour montrer le reste — et rien n'y était actionnable.",
|
||||||
"Diplômes, RDA : les boutons de comparaison et de remplissage des districts indiquent qu'ils travaillent, et la comparaison rend compte de son propre résultat — y compris « aucune divergence » et « aucun contact russe », qui sont des résultats et étaient jusqu'ici indiscernables d'un bouton sans effet. Une comparaison en échec affichait par ailleurs son erreur à côté de l'AUTRE bouton.",
|
"Diplômes, RDA : les boutons de comparaison et de remplissage des districts indiquent qu'ils travaillent, et la comparaison rend compte de son propre résultat — y compris « aucune divergence » et « aucun contact russe », qui sont des résultats et étaient jusqu'ici indiscernables d'un bouton sans effet. Une comparaison en échec affichait par ailleurs son erreur à côté de l'AUTRE bouton.",
|
||||||
"La liste déroulante des satellites suit maintenant la liste au fil des modifications : le champ la lisait une seule fois à l'apparition du panneau, si bien qu'une liste enregistrée ensuite n'apparaissait qu'après un redémarrage."
|
"La liste déroulante des satellites suit maintenant la liste au fil des modifications : le champ la lisait une seule fois à l'apparition du panneau, si bien qu'une liste enregistrée ensuite n'apparaissait qu'après un redémarrage.",
|
||||||
|
"Les préférences ne se redessinent plus au rythme de la fenêtre principale. Étant un enfant de la vue principale, tout le panneau était reconstruit à chaque spot du cluster et à chaque mise à jour CAT — plusieurs fois par seconde un soir chargé — ce qui donnait l'impression d'une page qui se rafraîchit sans arrêt et faisait rater les clics, l'élément sous le pointeur étant remplacé entre l'appui et le relâchement.",
|
||||||
|
"Les listes déroulantes s'ouvrent par-dessus ce qui les entoure au lieu d'être coupées par lui. Celle des satellites n'affichait qu'une entrée sur huit, tronquée par le panneau qui la contenait."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+39
-19
@@ -4194,6 +4194,41 @@ export default function App() {
|
|||||||
setLookupResult(null);
|
setLookupResult(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Settings dialog is memoised (see SettingsModal), which only helps while
|
||||||
|
// its props hold still. These three would otherwise be new functions on every
|
||||||
|
// App render — several times a second with a cluster running — and the memo
|
||||||
|
// would compare unequal every time and re-render the whole panel anyway.
|
||||||
|
const openEditRef = useRef<(id: number) => void>(() => {});
|
||||||
|
// Refreshed on every render, read only when Settings opens: the dialog gets a
|
||||||
|
// callback whose identity never changes, and still calls the current one.
|
||||||
|
useEffect(() => { openEditRef.current = (id: number) => { void openEdit(id); }; });
|
||||||
|
// The stable wrapper the dialog actually receives.
|
||||||
|
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
|
||||||
|
const closeSettings = useCallback(() => {
|
||||||
|
setShowSettings(false);
|
||||||
|
setSettingsSection(undefined);
|
||||||
|
refreshChaseNew();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const onSettingsSaved = useCallback(() => {
|
||||||
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||||
|
// Drop the cached spot statuses. They are computed once per call+band+mode
|
||||||
|
// and never expire, so a rule change in Settings — grouping the digital
|
||||||
|
// modes into one slot, above all — left every spot already on screen
|
||||||
|
// showing the answer to the OLD question.
|
||||||
|
setSpotStatus({});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const onSettingsPaneChanged = useCallback((side: 'left' | 'right' | 'p3' | 'p4' | 'layout', v: string) => {
|
||||||
|
// Applied from the CHOSEN value, never from a re-read of the DB: the write
|
||||||
|
// is async and the layout must not lag a click behind.
|
||||||
|
if (side === 'left') setMainPaneLeft(v as MainPaneKind);
|
||||||
|
else if (side === 'right') setMainPaneRight(v as MainPaneKind);
|
||||||
|
else if (side === 'p3') setMainPane3(v as MainPaneKind);
|
||||||
|
else if (side === 'p4') setMainPane4(v as MainPaneKind);
|
||||||
|
else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function openEdit(id: number) {
|
async function openEdit(id: number) {
|
||||||
try { setEditingQSO(await GetQSO(id)); }
|
try { setEditingQSO(await GetQSO(id)); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
@@ -8495,26 +8530,11 @@ export default function App() {
|
|||||||
|
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
onEditQSO={openEdit}
|
onEditQSO={openQSOFromSettings}
|
||||||
initialSection={settingsSection}
|
initialSection={settingsSection}
|
||||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); refreshChaseNew(); }}
|
onClose={closeSettings}
|
||||||
onSaved={() => {
|
onSaved={onSettingsSaved}
|
||||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
onMainPaneChanged={onSettingsPaneChanged}
|
||||||
// Drop the cached spot statuses. They are computed once per
|
|
||||||
// call+band+mode and never expire, so a rule change in Settings —
|
|
||||||
// grouping the digital modes into one slot, above all — left every
|
|
||||||
// spot already on screen showing the answer to the OLD question.
|
|
||||||
setSpotStatus({});
|
|
||||||
}}
|
|
||||||
onMainPaneChanged={(side, v) => {
|
|
||||||
// Applied from the CHOSEN value, never from a re-read of the DB:
|
|
||||||
// the write is async and the layout must not lag a click behind.
|
|
||||||
if (side === 'left') setMainPaneLeft(v as MainPaneKind);
|
|
||||||
else if (side === 'right') setMainPaneRight(v as MainPaneKind);
|
|
||||||
else if (side === 'p3') setMainPane3(v as MainPaneKind);
|
|
||||||
else if (side === 'p4') setMainPane4(v as MainPaneKind);
|
|
||||||
else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
|
|
||||||
}}
|
|
||||||
flexAvailable={catState.backend === 'flex'}
|
flexAvailable={catState.backend === 'flex'}
|
||||||
icomAvailable={catState.backend === 'icom'}
|
icomAvailable={catState.backend === 'icom'}
|
||||||
yaesuAvailable={catState.backend === 'yaesu'}
|
yaesuAvailable={catState.backend === 'yaesu'}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
@@ -1524,7 +1524,19 @@ function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) {
|
// SETTINGS DOES NOT RE-RENDER WITH THE MAIN WINDOW.
|
||||||
|
//
|
||||||
|
// It is a child of App, and App re-renders on every cluster spot, every CAT
|
||||||
|
// status push, every decode — several times a second on a busy evening. Each of
|
||||||
|
// those re-rendered this entire panel, which is large enough that the rebuild
|
||||||
|
// takes longer than the gap between them: the page looked like it was
|
||||||
|
// refreshing ten times a second and buttons stopped responding, because the
|
||||||
|
// element under the pointer was replaced between the press and the release.
|
||||||
|
//
|
||||||
|
// memo() cuts that off. It only works if the props hold still, which is why
|
||||||
|
// App passes callbacks that do not change identity on every render — see the
|
||||||
|
// useCallback wrappers there.
|
||||||
|
function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -7716,6 +7728,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SettingsModal = memo(SettingsModalImpl);
|
||||||
|
|
||||||
// PortInput — a TCP port field you can actually clear.
|
// PortInput — a TCP port field you can actually clear.
|
||||||
//
|
//
|
||||||
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
|
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -36,6 +37,10 @@ export function Combobox({
|
|||||||
// otherwise "open" onto COM7 alone.
|
// otherwise "open" onto COM7 alone.
|
||||||
const [browse, setBrowse] = useState(false);
|
const [browse, setBrowse] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
// Where the portalled menu goes. Measured from the field itself, and
|
||||||
|
// re-measured while it is open, so scrolling the panel underneath does not
|
||||||
|
// leave the list floating over the wrong row.
|
||||||
|
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDoc(e: MouseEvent) {
|
function onDoc(e: MouseEvent) {
|
||||||
@@ -45,6 +50,28 @@ export function Combobox({
|
|||||||
return () => document.removeEventListener('mousedown', onDoc);
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const place = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
// Opens UPWARD when there is not enough room below — which is exactly
|
||||||
|
// where these fields tend to sit, at the bottom of a panel.
|
||||||
|
const height = 240;
|
||||||
|
const below = window.innerHeight - r.bottom;
|
||||||
|
const top = below < height + 8 ? Math.max(4, r.top - height - 4) : r.bottom + 4;
|
||||||
|
setMenuPos({ top, left: r.left, width: r.width });
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const filtered = !open ? []
|
const filtered = !open ? []
|
||||||
: browse ? options.slice(0, 60)
|
: browse ? options.slice(0, 60)
|
||||||
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
||||||
@@ -121,8 +148,16 @@ export function Combobox({
|
|||||||
<ChevronDown className="size-3.5" />
|
<ChevronDown className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{open && filtered.length > 0 && (
|
{/* THE MENU IS PORTALLED to the body, and positioned from the field's own
|
||||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
rectangle. As an absolutely-positioned child it was clipped by whichever
|
||||||
|
scrolling or overflow-hidden box it happened to sit in: in the details
|
||||||
|
panel it was cut off after the first row, and a list showing one entry
|
||||||
|
of eight is worse than no list at all. */}
|
||||||
|
{open && filtered.length > 0 && createPortal(
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }}
|
||||||
|
className="z-[100] max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg text-xs"
|
||||||
|
>
|
||||||
{filtered.map((o) => (
|
{filtered.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o}
|
key={o}
|
||||||
@@ -134,7 +169,8 @@ export function Combobox({
|
|||||||
{o}
|
{o}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user