feat(main): draggable divider between the two panes

The Main tab was a fixed 50/50 grid, but a map and a cluster list want
very different widths and which one deserves the room changes with what
the operator is doing.

The share is clamped to 15..85: a pane can be made small but never
dragged out of existence, because recovering from that would mean
grabbing a divider no longer on screen. Double-click restores even.

The drag uses pointer capture on the divider — without it Leaflet
swallows the moves as soon as the cursor crosses the map. Persisted
through writeUiPref, so it travels with data/ like the other UI prefs.
This commit is contained in:
2026-08-01 11:11:32 +02:00
parent d8b7b86eb7
commit a83acb0f9a
4 changed files with 53 additions and 5 deletions
+46 -1
View File
@@ -871,6 +871,36 @@ export default function App() {
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
});
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85
// so a pane can be made small but never dragged out of existence — recovering
// from that means finding a divider that is no longer on screen.
const [mainSplit, setMainSplit] = useState<number>(() => {
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
});
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
const mainSplitRef = useRef<HTMLDivElement | null>(null);
const startMainSplitDrag = (e: React.PointerEvent) => {
e.preventDefault();
const host = mainSplitRef.current;
if (!host) return;
// Pointer capture on the divider, so dragging over a map keeps working —
// Leaflet would otherwise swallow the moves the moment the cursor entered it.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
const r = host.getBoundingClientRect();
if (r.width <= 0) return;
const pct = ((ev.clientX - r.left) / r.width) * 100;
setMainSplit(Math.min(85, Math.max(15, pct)));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false);
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
@@ -6018,8 +6048,23 @@ export default function App() {
{/* Two configurable panes (per-profile, Settings Main view).
Each side shows one of: great-circle map, locator map, cluster
or worked-before. */}
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
{/* The divider is draggable: a map and a cluster list want very
different widths, and which one deserves the room changes with
what the operator is doing. The share is persisted (portable),
and a double-click puts it back to even. */}
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
<div
role="separator"
aria-orientation="vertical"
title={t('main.splitTip')}
onPointerDown={startMainSplitDrag}
onDoubleClick={() => setMainSplit(50)}
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
>
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
</div>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
</div>
</TabsContent>
+2 -2
View File
@@ -51,7 +51,7 @@ const en: Dict = {
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
// Tabs
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
'tab.main': 'Main', 'main.splitTip': 'Drag to resize the panes — double-click to even them out', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
// Entry form
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
@@ -464,7 +464,7 @@ const fr: Dict = {
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
'tab.main': 'Principal', 'main.splitTip': 'Faites glisser pour redimensionner les volets — double-clic pour les égaliser', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
+1
View File
@@ -36,6 +36,7 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them