vojo/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts

224 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MutableRefObject, useEffect, useRef } from 'react';
import {
COMMIT_FRACTION,
DEAD_ZONE_PX,
EDGE_GUARD_PX,
MIN_COMMIT_PX,
RUBBER_BAND_FACTOR,
} from './geometry';
type Args = {
// Root element the touch listeners attach to. Touches outside this
// element never reach the pager — that's how we keep the gesture
// scoped to the listing surface and out of detail routes.
rootRef: MutableRefObject<HTMLDivElement | null>;
// Index of the currently active pane. Mirrored into a ref so the
// single bound effect reads fresh values without re-attaching.
activeIdx: number;
// Total number of panes. Used to clamp commit + rubber-band edges.
tabsCount: number;
// While true the listeners stay bound but every touchstart bails
// immediately. Used by the parent to suppress the gesture when an
// overlay sheet (settings, workspace switcher) is open — a swipe
// there shouldn't navigate sibling tabs.
disabled: boolean;
// Setter for the live drag delta. The pager component re-renders the
// strip transform on every change.
setDragPx: (px: number, dragging: boolean) => void;
// Commit a tab change. The caller is expected to reset dragPx to 0
// AND call navigate(replace) in the same React batch so the strip's
// transform jumps from (oldIdx, dragPx) to (newIdx, 0) in one render
// — CSS transition then animates the (small) remaining distance
// smoothly without an intermediate "snap back" flash.
commitTo: (idx: number) => void;
};
// Horizontal swipe driver for the mobile listing tab pager. Mirrors
// the shape of `useCurtainHandleGesture`: single listener bound to
// the pager root, refs for the latest snap/index state, axis-resolve
// in the dead-zone, rubber-band at boundaries, threshold-commit on
// release.
//
// Conflict resolution with other gestures sharing the same surface
// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is
// cooperative: every gesture-owner resolves axis inside a dead-zone
// and bails when its own axis doesn't dominate. The pager and the
// horseshoes resolve at 12px; the CURTAIN hooks resolve at 10px
// (`stream-header/geometry.ts::DIRECTION_DEAD_ZONE_PX`) — i.e. in the
// 1012px window the curtain decides first, by design (its vertical
// pull is the most frequent gesture and should feel the most eager).
// The pager wins horizontal; the others win vertical.
export function useMobileTabsPagerGesture({
rootRef,
activeIdx,
tabsCount,
disabled,
setDragPx,
commitTo,
}: Args): void {
const activeRef = useRef(activeIdx);
const countRef = useRef(tabsCount);
const disabledRef = useRef(disabled);
activeRef.current = activeIdx;
countRef.current = tabsCount;
disabledRef.current = disabled;
useEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
let startX: number | null = null;
let startY: number | null = null;
let engaged = false;
let bailed = false;
let lastDragPx = 0;
const reset = () => {
startX = null;
startY = null;
engaged = false;
bailed = false;
lastDragPx = 0;
};
const onTouchStart = (e: TouchEvent) => {
if (disabledRef.current) {
reset();
return;
}
if (e.touches.length !== 1) {
reset();
return;
}
const t = e.touches[0];
const vw = window.innerWidth;
// Android system back-gesture lives in the L/R edge strip in
// edge-to-edge mode. Ignore touches there so we don't fight it.
if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) {
reset();
return;
}
startX = t.clientX;
startY = t.clientY;
engaged = false;
bailed = false;
lastDragPx = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) {
// Second finger landed mid-gesture — abort without commit.
if (engaged) setDragPx(0, false);
reset();
bailed = true;
return;
}
// Defensive symmetry with onTouchStart's disabled check: a sheet
// opening async between touchstart and touchmove (e.g. atom flip
// from a delayed effect) shouldn't let an already-armed pager
// gesture commit through.
if (disabledRef.current) {
if (engaged) setDragPx(0, false);
reset();
bailed = true;
return;
}
if (startX === null || startY === null || bailed) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!engaged) {
// Wait for the finger to leave the dead-zone before deciding
// who owns the gesture. The pager only engages when |dx|
// strictly dominates |dy|; ties go to vertical (curtain +
// horseshoe pull-down feels more natural than horizontal
// commit for ambiguous gestures).
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
if (Math.abs(dy) >= Math.abs(dx)) {
bailed = true;
return;
}
engaged = true;
}
if (e.cancelable) e.preventDefault();
const vw = window.innerWidth;
const idx = activeRef.current;
const count = countRef.current;
let drag = dx;
// Rubber-band at the leftmost (dx > 0 = trying to go past idx 0)
// and rightmost (dx < 0 = trying to go past last idx) boundary.
// Soft attenuation — commit threshold can never be reached, so
// the spring-back on release lands us back on the current tab.
if (idx === 0 && dx > 0) drag = dx * RUBBER_BAND_FACTOR;
else if (idx === count - 1 && dx < 0) drag = dx * RUBBER_BAND_FACTOR;
// Clamp to ±one viewport so an overshooting swipe doesn't
// translate the strip into nonsense territory.
drag = Math.max(-vw, Math.min(vw, drag));
lastDragPx = drag;
setDragPx(drag, true);
};
const onTouchEnd = () => {
if (!engaged) {
reset();
return;
}
// Defensive recheck symmetric with onTouchStart / onTouchMove:
// an overlay sheet could have opened between the last touchmove
// and this touchend (atom flip from a delayed effect, a system
// dialog, etc.). Committing under those circumstances would
// navigate sibling tabs from beneath the overlay — same hazard
// the touchstart/move gates exist to prevent. Spring back
// instead.
if (disabledRef.current) {
setDragPx(0, false);
reset();
return;
}
const vw = window.innerWidth;
const idx = activeRef.current;
const count = countRef.current;
const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION);
let nextIdx = idx;
// Negative drag (finger moved left) → next tab to the right.
// Positive drag (finger moved right) → previous tab to the left.
if (lastDragPx <= -threshold && idx < count - 1) nextIdx = idx + 1;
else if (lastDragPx >= threshold && idx > 0) nextIdx = idx - 1;
if (nextIdx !== idx) {
commitTo(nextIdx);
} else {
// No commit — re-enable transition and animate the strip back
// to its resting position at the current tab.
setDragPx(0, false);
}
reset();
};
const onTouchCancel = () => {
// System cancel (incoming call, scroll-take-over, etc.) — never
// commit; just spring back if a drag was in flight.
if (engaged) setDragPx(0, false);
reset();
};
root.addEventListener('touchstart', onTouchStart, { passive: true });
root.addEventListener('touchmove', onTouchMove, { passive: false });
root.addEventListener('touchend', onTouchEnd, { passive: true });
root.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
root.removeEventListener('touchstart', onTouchStart);
root.removeEventListener('touchmove', onTouchMove);
root.removeEventListener('touchend', onTouchEnd);
root.removeEventListener('touchcancel', onTouchCancel);
};
// setDragPx / commitTo are stable useCallbacks from the parent;
// activeIdx / tabsCount are mirrored via the refs above so the
// listener reads fresh values without re-binding on every nav.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootRef, setDragPx, commitTo]);
}