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:
+46
-1
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user