feat: persistent Ctrl+wheel zoom, saved award-column widths, F9 + hide-empty CW macros

- Zoom: our own Ctrl+wheel zoom (CSS zoom on the root, 50–250%), persisted in
  localStorage and restored at startup; Ctrl+0 resets. Replaces the non-persistent
  native WebView2 zoom. The freq-digit wheel now ignores Ctrl so it passes through
  to zoom.
- Award column widths: they were stripped from AG Grid's saved column state (that
  strip fixes a visibility desync) which also dropped their width. Now persisted
  separately per award code (localStorage + portable DB copy) and re-applied on
  rebuild/reopen.
- CW keyer widget: macros padded to 9 (F1–F9 slot) and empty macros hidden like
  the voice keyer, with the F-number kept tied to the real macro index so the
  F-key shortcuts still line up. New QRZ? default for F9.
This commit is contained in:
2026-07-25 13:14:05 +02:00
parent 6be0f43dd0
commit 91653bca57
6 changed files with 102 additions and 9 deletions
+58 -3
View File
@@ -328,6 +328,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
saveState(AWARD_SHOWN_KEY, [...next]);
}, []);
// Award-column WIDTHS are persisted separately too, for the same reason as
// visibility: award columns are stripped from AG Grid's saved column-state
// round-trip, so their width would otherwise reset to the default on reopen.
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
const awardWidthsRef = useRef<Record<string, number>>({});
const awardWidthsInit = useRef(false);
if (!awardWidthsInit.current) {
awardWidthsInit.current = true;
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
}
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
// apply them to any award columns already on screen.
useEffect(() => {
if (loadLocal(AWARD_WIDTH_KEY)) return;
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
if (!remote || !remote.length) return;
for (const it of remote as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
seedLocal(AWARD_WIDTH_KEY, remote);
const api = gridRef.current?.api;
if (api && awardCols?.length) {
const ups = awardCols
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
.filter((u) => !!u.newWidth);
if (ups.length) api.setColumnWidths(ups);
}
});
}, []);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
@@ -354,7 +387,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110,
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
cellClass: 'text-[11px]',
// Visibility comes from the persisted award-code set, so a column the user
// showed reappears on reopen and one they didn't stays hidden.
@@ -370,11 +403,17 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
useEffect(() => {
const api = gridRef.current?.api;
if (!api || !awardCols?.length) return;
const widthUps: { key: string; newWidth: number }[] = [];
for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
// Re-apply the saved width — AG Grid keeps an existing column's width across
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
const w = awardWidthsRef.current[a.code.toUpperCase()];
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
}
if (widthUps.length) api.setColumnWidths(widthUps);
}, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({
@@ -421,8 +460,24 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(colStateKey, stripAwardCols(state));
}, []);
if (!state) return;
saveState(colStateKey, stripAwardCols(state));
// Award columns are stripped above, so persist their widths on the side.
let changed = false;
for (const s of state) {
const id = String((s as any)?.colId ?? '');
if (id.startsWith('award_') && (s as any).width) {
const code = id.slice('award_'.length).toUpperCase();
if (awardWidthsRef.current[code] !== (s as any).width) {
awardWidthsRef.current[code] = (s as any).width;
changed = true;
}
}
}
if (changed) {
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
}
}, [colStateKey, AWARD_WIDTH_KEY]);
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
// award column (both change the memo → restoringRef flips true at line 316). Each