feat(RegionMap): introduce interaction state machine for improved event handling

This commit is contained in:
2025-08-11 19:45:30 +02:00
parent 3b20e07b17
commit dad6d79740

View File

@@ -126,6 +126,10 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const [shiftCenter, setShiftCenter] = useState<Position | null>(null); const [shiftCenter, setShiftCenter] = useState<Position | null>(null);
const [shiftRadius, setShiftRadius] = useState<number>(0); const [shiftRadius, setShiftRadius] = useState<number>(0);
// Interaction state machine (lightweight)
type InteractionMode = 'idle' | 'holding' | 'panning' | 'selecting' | 'shiftSelecting';
const [mode, setMode] = useState<InteractionMode>('idle');
// When focusSystem changes, set an expiry 20s in the future // When focusSystem changes, set an expiry 20s in the future
useEffect(() => { useEffect(() => {
if (focusSystem) { if (focusSystem) {
@@ -535,6 +539,17 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!svgRef.current) return; if (!svgRef.current) return;
// If context menu is open, left-click closes it and no selection should happen
if (contextMenu) {
if (e.button === 0) setContextMenu(null);
clearSelectTimer();
setIsSelecting(false);
setIsPanning(false);
setShiftSelecting(false);
setMode('idle');
return;
}
mouseButtonRef.current = e.button; mouseButtonRef.current = e.button;
// SHIFT + VIA mode: start circle selection (left button only) // SHIFT + VIA mode: start circle selection (left button only)
@@ -545,6 +560,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setShiftSelecting(true); setShiftSelecting(true);
setShiftCenter(svgPt); setShiftCenter(svgPt);
setShiftRadius(0); setShiftRadius(0);
setMode('shiftSelecting');
// cancel any hold-to-select/pan intents // cancel any hold-to-select/pan intents
setIsSelecting(false); setIsSelecting(false);
setIsPanning(false); setIsPanning(false);
@@ -557,6 +573,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
if (e.button !== 0) { if (e.button !== 0) {
clearSelectTimer(); clearSelectTimer();
setIsSelecting(false); setIsSelecting(false);
setMode('idle');
return; return;
} }
@@ -572,11 +589,13 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
// start delayed select mode timer // start delayed select mode timer
setIsSelecting(false); setIsSelecting(false);
setMode('holding');
clearSelectTimer(); clearSelectTimer();
selectTimerRef.current = window.setTimeout(() => { selectTimerRef.current = window.setTimeout(() => {
setIsSelecting(true); setIsSelecting(true);
setMode('selecting');
}, SELECT_HOLD_MS); }, SELECT_HOLD_MS);
}, [positions, systems, viaMode]); }, [positions, systems, viaMode, contextMenu]);
const handleMouseMove = useCallback((e: React.MouseEvent) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
// if dragging node, delegate // if dragging node, delegate
@@ -590,6 +609,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const dx = svgPt.x - shiftCenter.x; const dx = svgPt.x - shiftCenter.x;
const dy = svgPt.y - shiftCenter.y; const dy = svgPt.y - shiftCenter.y;
setShiftRadius(Math.sqrt(dx * dx + dy * dy)); setShiftRadius(Math.sqrt(dx * dx + dy * dy));
setMode('shiftSelecting');
return; return;
} }
@@ -601,10 +621,11 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const deltaY = (lastPanPoint.y - currentPoint.y) * (viewBox.height / rect.height); const deltaY = (lastPanPoint.y - currentPoint.y) * (viewBox.height / rect.height);
setViewBox(prev => ({ ...prev, x: prev.x + deltaX, y: prev.y + deltaY })); setViewBox(prev => ({ ...prev, x: prev.x + deltaX, y: prev.y + deltaY }));
setLastPanPoint(currentPoint); setLastPanPoint(currentPoint);
setMode('panning');
return; return;
} }
// determine if we should start panning // determine if we should start panning (from holding)
const down = downClientPointRef.current; const down = downClientPointRef.current;
if (down && !isSelecting) { if (down && !isSelecting) {
const dx = e.clientX - down.x; const dx = e.clientX - down.x;
@@ -616,6 +637,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setIsSelecting(false); setIsSelecting(false);
setIndicatedSystem(null); setIndicatedSystem(null);
setIsPanning(true); setIsPanning(true);
setMode('panning');
// seed pan origin with current // seed pan origin with current
const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
setLastPanPoint(currentPoint); setLastPanPoint(currentPoint);
@@ -628,6 +650,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const svgPt = clientToSvg(e.clientX, e.clientY); const svgPt = clientToSvg(e.clientX, e.clientY);
const near = findNearestSystem(svgPt.x, svgPt.y); const near = findNearestSystem(svgPt.x, svgPt.y);
setIndicatedSystem(near); setIndicatedSystem(near);
setMode('selecting');
} }
}, [draggingNode, isPanning, lastPanPoint, viewBox.width, viewBox.height, isSelecting, positions, systems, shiftSelecting, shiftCenter]); }, [draggingNode, isPanning, lastPanPoint, viewBox.width, viewBox.height, isSelecting, positions, systems, shiftSelecting, shiftCenter]);
@@ -635,6 +658,22 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
// if dragging node, delegate // if dragging node, delegate
if (draggingNode) { if (e) handleSvgMouseUp(e); return; } if (draggingNode) { if (e) handleSvgMouseUp(e); return; }
// If context menu open, left click should just close it; do not select
if (contextMenu && mouseButtonRef.current === 0) {
setContextMenu(null);
clearSelectTimer();
setIsPanning(false);
setIsSelecting(false);
setIndicatedSystem(null);
setShiftSelecting(false);
setShiftCenter(null);
setShiftRadius(0);
downClientPointRef.current = null;
mouseButtonRef.current = null;
setMode('idle');
return;
}
// Commit shift selection if active (only if left button initiated) // Commit shift selection if active (only if left button initiated)
if (shiftSelecting) { if (shiftSelecting) {
if (mouseButtonRef.current === 0) { if (mouseButtonRef.current === 0) {
@@ -649,6 +688,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setIndicatedSystem(null); setIndicatedSystem(null);
downClientPointRef.current = null; downClientPointRef.current = null;
mouseButtonRef.current = null; mouseButtonRef.current = null;
setMode('idle');
return; return;
} }
@@ -660,6 +700,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setIndicatedSystem(null); setIndicatedSystem(null);
downClientPointRef.current = null; downClientPointRef.current = null;
mouseButtonRef.current = null; mouseButtonRef.current = null;
setMode('idle');
return; return;
} }
@@ -668,6 +709,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
if (isPanning) { if (isPanning) {
setIsPanning(false); setIsPanning(false);
mouseButtonRef.current = null; mouseButtonRef.current = null;
setMode('idle');
return; return;
} }
@@ -687,7 +729,8 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setIndicatedSystem(null); setIndicatedSystem(null);
downClientPointRef.current = null; downClientPointRef.current = null;
mouseButtonRef.current = null; mouseButtonRef.current = null;
}, [draggingNode, isPanning, indicatedSystem, positions, systems, shiftSelecting, commitShiftSelection]); setMode('idle');
}, [draggingNode, isPanning, indicatedSystem, positions, systems, shiftSelecting, commitShiftSelection, contextMenu]);
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
@@ -870,6 +913,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setShiftRadius(0); setShiftRadius(0);
downClientPointRef.current = null; downClientPointRef.current = null;
mouseButtonRef.current = null; mouseButtonRef.current = null;
setMode('idle');
}; };
window.addEventListener('mouseup', onWindowMouseUp); window.addEventListener('mouseup', onWindowMouseUp);
return () => window.removeEventListener('mouseup', onWindowMouseUp); return () => window.removeEventListener('mouseup', onWindowMouseUp);
@@ -908,7 +952,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
width="100%" width="100%"
height="100%" height="100%"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`} viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
className={`${(isSelecting || shiftSelecting) ? 'cursor-crosshair' : (isPanning ? 'cursor-grabbing' : 'cursor-grab')}`} className={`${(mode === 'selecting' || mode === 'shiftSelecting') ? 'cursor-crosshair' : (mode === 'panning' ? 'cursor-grabbing' : 'cursor-grab')}`}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={(e) => handleMouseUp(e)} onMouseUp={(e) => handleMouseUp(e)}