224 lines
8.2 KiB
TypeScript
224 lines
8.2 KiB
TypeScript
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
|
||
// 10–12px 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]);
|
||
}
|