199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
import { MutableRefObject, useEffect, useRef } from 'react';
|
|
import { isNativePlatform } from '../../utils/capacitor';
|
|
import {
|
|
ACTIVE_CLOSE_THRESHOLD_PX,
|
|
COMMIT_THRESHOLD,
|
|
DIRECTION_DEAD_ZONE_PX,
|
|
PEEK_TRAVEL_PX,
|
|
RUBBER_BAND,
|
|
} from './geometry';
|
|
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
|
|
|
type Args = {
|
|
// The scroll viewport that hosts the chat list inside the curtain.
|
|
// Touch events fire here; gestures engage when the list is at
|
|
// `scrollTop === 0` (peek path) or any scrollTop (form-close path).
|
|
scrollRef: MutableRefObject<HTMLDivElement | null>;
|
|
// Current snap stop. Read at touchstart to decide gesture meaning.
|
|
// Mirrored into a ref so the listener (bound once) reads fresh values.
|
|
snap: CurtainSnap;
|
|
// Setter for the live drag delta during touchmove. The hook reads
|
|
// `liveDragPx` from the parent state too, so React drives the
|
|
// curtain's `top` re-render — no direct DOM writes, no inline-vs-
|
|
// React transition coordination.
|
|
setLiveDrag: (px: number, dragging: boolean) => void;
|
|
// Commit a new snap stop on release.
|
|
commit: (next: CurtainSnap) => void;
|
|
};
|
|
|
|
// Touch-gesture driver for the curtain. Native-only: on web/PC the
|
|
// listeners aren't attached at all.
|
|
//
|
|
// Peek path: drag down from `closed` rubber-bands the live delta and
|
|
// on release past the threshold commits to `peek` (both chips
|
|
// revealed in one motion). Drag UP from `peek` retreats to `closed`.
|
|
//
|
|
// Form-close path: drag UP from a form snap tracks the finger 1:1; on
|
|
// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`.
|
|
export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args): void {
|
|
// Mirror snap into a ref so the listener — bound once via useEffect —
|
|
// always reads the freshest value without re-attaching.
|
|
const snapRef = useRef<CurtainSnap>(snap);
|
|
snapRef.current = snap;
|
|
|
|
useEffect(() => {
|
|
if (!isNativePlatform()) return undefined;
|
|
const list = scrollRef.current;
|
|
if (!list) return undefined;
|
|
|
|
let startY: number | null = null;
|
|
let direction: 'up' | 'down' | null = null;
|
|
let engaged = false;
|
|
let lastDelta = 0;
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1) return;
|
|
startY = e.touches[0].clientY;
|
|
direction = null;
|
|
engaged = false;
|
|
lastDelta = 0;
|
|
// Peek-reveal requires scrollTop === 0 (no content above to
|
|
// scroll). Form-close engages regardless of scrollTop (the form
|
|
// is open, the list scroll is the close target).
|
|
if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) {
|
|
startY = null;
|
|
}
|
|
};
|
|
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1) {
|
|
// Second finger landed mid-gesture — abort.
|
|
startY = null;
|
|
direction = null;
|
|
if (engaged) setLiveDrag(0, false);
|
|
engaged = false;
|
|
lastDelta = 0;
|
|
return;
|
|
}
|
|
if (startY === null) {
|
|
// Active mode may re-arm startY here if onTouchStart bailed.
|
|
if (isFormSnap(snapRef.current)) {
|
|
startY = e.touches[0].clientY;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const delta = e.touches[0].clientY - startY;
|
|
const currentSnap = snapRef.current;
|
|
|
|
// Resolve a direction once the finger crosses the dead-zone.
|
|
if (direction === null) {
|
|
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
|
|
direction = delta > 0 ? 'down' : 'up';
|
|
|
|
// Direction guards: nothing higher than `closed`; nothing
|
|
// lower than `peek`; form snaps only close (up).
|
|
if (currentSnap === 'closed' && direction === 'up') {
|
|
startY = null;
|
|
direction = null;
|
|
return;
|
|
}
|
|
if (currentSnap === 'peek' && direction === 'down') {
|
|
startY = null;
|
|
direction = null;
|
|
return;
|
|
}
|
|
if (isFormSnap(currentSnap) && direction === 'down') {
|
|
startY = null;
|
|
direction = null;
|
|
return;
|
|
}
|
|
}
|
|
|
|
engaged = true;
|
|
e.preventDefault();
|
|
|
|
if (isFormSnap(currentSnap)) {
|
|
// Form close: finger moves UP (delta < 0). Track 1:1, capped
|
|
// at 0 so an accidental downward jitter doesn't push the
|
|
// curtain below its resting position.
|
|
lastDelta = Math.min(0, delta);
|
|
} else {
|
|
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
|
|
// more chips; up (delta < 0) retreats toward `closed`. Bounds
|
|
// are enforced by the direction guards above plus the snap
|
|
// clamp on touchend, so we don't clamp here.
|
|
lastDelta = delta * RUBBER_BAND;
|
|
}
|
|
setLiveDrag(lastDelta, true);
|
|
};
|
|
|
|
const onTouchEnd = () => {
|
|
if (!engaged) {
|
|
startY = null;
|
|
direction = null;
|
|
return;
|
|
}
|
|
|
|
const currentSnap = snapRef.current;
|
|
let next: CurtainSnap = currentSnap;
|
|
|
|
if (isFormSnap(currentSnap)) {
|
|
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
|
next = 'closed';
|
|
}
|
|
} else {
|
|
// Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of
|
|
// the FULL peek travel — the rubber-banded drag must reach
|
|
// ≈90% of the closed→peek distance before release for the
|
|
// snap to flip. Anything shorter springs back so a brisk-but-
|
|
// short drag doesn't accidentally open (or close) the curtain.
|
|
const progress = lastDelta / PEEK_TRAVEL_PX;
|
|
if (Math.abs(progress) >= COMMIT_THRESHOLD) {
|
|
if (currentSnap === 'closed' && progress > 0) next = 'peek';
|
|
else if (currentSnap === 'peek' && progress < 0) next = 'closed';
|
|
}
|
|
}
|
|
|
|
if (next !== currentSnap) {
|
|
// commit() also resets liveDragPx + isDragging to 0/false in
|
|
// one batched update — React renders the curtain at the new
|
|
// resting top with the snap transition re-enabled.
|
|
commit(next);
|
|
} else {
|
|
// No commit: drop the live drag back to 0 with transition
|
|
// active so the curtain springs back to its resting position.
|
|
setLiveDrag(0, false);
|
|
}
|
|
|
|
startY = null;
|
|
direction = null;
|
|
engaged = false;
|
|
lastDelta = 0;
|
|
};
|
|
|
|
const onTouchCancel = () => {
|
|
// System cancel never commits — always snap back to current snap.
|
|
if (engaged) setLiveDrag(0, false);
|
|
startY = null;
|
|
direction = null;
|
|
engaged = false;
|
|
lastDelta = 0;
|
|
};
|
|
|
|
list.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
list.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
list.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
list.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
|
return () => {
|
|
list.removeEventListener('touchstart', onTouchStart);
|
|
list.removeEventListener('touchmove', onTouchMove);
|
|
list.removeEventListener('touchend', onTouchEnd);
|
|
list.removeEventListener('touchcancel', onTouchCancel);
|
|
};
|
|
// setLiveDrag/commit are stable useCallbacks; scrollRef is stable.
|
|
// `snap` is mirrored via snapRef written above on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [scrollRef, setLiveDrag, commit]);
|
|
}
|