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>