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:
+28
-2
@@ -231,7 +231,7 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
||||
{intPart}.
|
||||
{khz.split('').map((d, i) => (
|
||||
<span key={i}
|
||||
onWheel={(e) => { e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }}
|
||||
onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }}
|
||||
className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
|
||||
title="Scroll to change frequency">
|
||||
{d}
|
||||
@@ -843,6 +843,30 @@ export default function App() {
|
||||
const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow)
|
||||
const wkEsmRef = useRef(false);
|
||||
useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]);
|
||||
|
||||
// Persistent Ctrl+wheel zoom. The native WebView2 Ctrl+wheel zoom isn't saved,
|
||||
// so we run our own: CSS `zoom` on the root element, factor stored in
|
||||
// localStorage and restored at startup. Ctrl+0 resets to 100%.
|
||||
useEffect(() => {
|
||||
const KEY = 'opslog.uiZoom';
|
||||
let z = parseFloat(localStorage.getItem(KEY) || '1');
|
||||
if (!Number.isFinite(z) || z <= 0) z = 1;
|
||||
const apply = () => { (document.documentElement.style as any).zoom = String(z); };
|
||||
apply();
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge)
|
||||
e.preventDefault();
|
||||
z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10));
|
||||
localStorage.setItem(KEY, String(z));
|
||||
apply();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(); }
|
||||
};
|
||||
window.addEventListener('wheel', onWheel, { passive: false });
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => { window.removeEventListener('wheel', onWheel); window.removeEventListener('keydown', onKey); };
|
||||
}, []);
|
||||
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
|
||||
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
|
||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||
@@ -2237,7 +2261,9 @@ export default function App() {
|
||||
setWkEnabled(!!s.enabled);
|
||||
setWkPort(s.port ?? '');
|
||||
setWkWpm(s.wpm ?? 25);
|
||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||
// Pad to 9 slots (F1–F9) so an F9 macro always exists to fill; empty ones
|
||||
// are hidden in the widget.
|
||||
{ const mac = ((s.macros ?? []) as WKMacro[]).slice(); while (mac.length < 9) mac.push({ label: '', text: '' }); setWkMacros(mac); }
|
||||
setWkEscClears(s.esc_clears_call !== false);
|
||||
setWkSendOnType(!!s.send_on_type);
|
||||
setWkEsm(!!s.esm);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1407,7 +1407,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const locs: any = await ListTQSLStationLocations();
|
||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||
try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
|
||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||
@@ -1441,7 +1441,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||
try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
|
||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||
|
||||
@@ -230,9 +230,14 @@ export function WinkeyerPanel({
|
||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short.
|
||||
Empty macros (no label AND no text) are hidden, like the voice keyer;
|
||||
the F-number stays tied to the macro's real index so shortcuts match. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
{macros
|
||||
.map((m, i) => ({ m, i }))
|
||||
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
|
||||
.map(({ m, i }) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user