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:
@@ -13688,6 +13688,7 @@ func defaultWKMacros() []WKMacro {
|
|||||||
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
||||||
{Label: "QRL?", Text: "QRL? "},
|
{Label: "QRL?", Text: "QRL? "},
|
||||||
{Label: "AGN", Text: "AGN "},
|
{Label: "AGN", Text: "AGN "},
|
||||||
|
{Label: "QRZ?", Text: "QRZ? DE <MY_CALL> "},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"version": "0.21.1",
|
"version": "0.21.1",
|
||||||
"date": "2026-07-24",
|
"date": "2026-07-24",
|
||||||
"en": [
|
"en": [
|
||||||
|
"Ctrl + mouse wheel zoom is now remembered across restarts (Ctrl+0 resets to 100%).",
|
||||||
|
"Log grid: award column widths are now saved like the other columns, so a width you set survives a restart.",
|
||||||
|
"CW keyer widget: added an F9 macro slot, and empty macros are now hidden (like the voice keyer) — fill them in Settings → CW Keyer.",
|
||||||
"New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains <LOGQSO>.",
|
"New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains <LOGQSO>.",
|
||||||
"The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.",
|
"The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.",
|
||||||
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
|
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
|
||||||
@@ -15,6 +18,9 @@
|
|||||||
"Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references."
|
"Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
|
"Le zoom Ctrl + molette est maintenant conservé après un redémarrage (Ctrl+0 remet à 100 %).",
|
||||||
|
"Grille du log : les largeurs des colonnes de diplômes sont désormais sauvegardées comme les autres colonnes, une largeur réglée survit au redémarrage.",
|
||||||
|
"Widget keyer CW : ajout d'un emplacement de macro F9, et les macros vides sont maintenant masquées (comme le keyer vocal) — remplis-les dans Réglages → Keyer CW.",
|
||||||
"Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient <LOGQSO>.",
|
"Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient <LOGQSO>.",
|
||||||
"L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.",
|
"L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.",
|
||||||
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
|
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
|
||||||
|
|||||||
+28
-2
@@ -231,7 +231,7 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
|||||||
{intPart}.
|
{intPart}.
|
||||||
{khz.split('').map((d, i) => (
|
{khz.split('').map((d, i) => (
|
||||||
<span key={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"
|
className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
|
||||||
title="Scroll to change frequency">
|
title="Scroll to change frequency">
|
||||||
{d}
|
{d}
|
||||||
@@ -843,6 +843,30 @@ export default function App() {
|
|||||||
const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow)
|
const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow)
|
||||||
const wkEsmRef = useRef(false);
|
const wkEsmRef = useRef(false);
|
||||||
useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]);
|
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
|
// 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
|
// 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,
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
@@ -2237,7 +2261,9 @@ export default function App() {
|
|||||||
setWkEnabled(!!s.enabled);
|
setWkEnabled(!!s.enabled);
|
||||||
setWkPort(s.port ?? '');
|
setWkPort(s.port ?? '');
|
||||||
setWkWpm(s.wpm ?? 25);
|
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);
|
setWkEscClears(s.esc_clears_call !== false);
|
||||||
setWkSendOnType(!!s.send_on_type);
|
setWkSendOnType(!!s.send_on_type);
|
||||||
setWkEsm(!!s.esm);
|
setWkEsm(!!s.esm);
|
||||||
|
|||||||
@@ -328,6 +328,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
saveState(AWARD_SHOWN_KEY, [...next]);
|
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>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -354,7 +387,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Visibility comes from the persisted award-code set, so a column the user
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// showed reappears on reopen and one they didn't stays hidden.
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
@@ -370,11 +403,17 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api || !awardCols?.length) return;
|
if (!api || !awardCols?.length) return;
|
||||||
|
const widthUps: { key: string; newWidth: number }[] = [];
|
||||||
for (const a of awardCols) {
|
for (const a of awardCols) {
|
||||||
const want = awardShown.has(a.code.toUpperCase());
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
const col = api.getColumn(`award_${a.code}`);
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
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]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
@@ -421,8 +460,24 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
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
|
// 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
|
// 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();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
} 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 { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
try { setEmailCfg(await GetEmailSettings() 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 { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
try { setExtSvc(await GetExternalServices() 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 { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
try { setEqslCfg(await QSLGetEmailTemplates() 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>}
|
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||||
</div>
|
</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">
|
<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
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user