feat(RegionMap): introduce interaction state machine for improved event handling
This commit is contained in:
@@ -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)}
|
||||||
|
Reference in New Issue
Block a user