341 lines
14 KiB
TypeScript
341 lines
14 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,
|
||
PIN_COMMIT_THRESHOLD,
|
||
PIN_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;
|
||
// Per-pane pinned overlay. When true the curtain renders at the top
|
||
// regardless of `snap`, and the gesture interprets a drag-down as
|
||
// unpin instead of peek-reveal. Also mirrored into a ref for the
|
||
// bound-once listener.
|
||
pinned: boolean;
|
||
// Setter for the pinned overlay; called on release once the user's
|
||
// up-drag (from closed) or down-drag (from pinned) past the commit
|
||
// threshold qualifies the gesture.
|
||
setPinned: (next: boolean) => void;
|
||
// 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;
|
||
// Suppress gesture binding entirely. Used to gate pinning when a
|
||
// bottom sheet (Settings, workspace switcher) is open — otherwise
|
||
// a drag-up on the visible list above the sheet would mutate the
|
||
// pin atom underneath the sheet and the user would see an unexpected
|
||
// pinned curtain on sheet dismissal. Also gated when this pane is
|
||
// inactive inside the swipe pager so stray touches on offscreen
|
||
// panes can't mutate any tab's pin.
|
||
disabled?: boolean;
|
||
};
|
||
|
||
// Touch-gesture driver for the curtain. Native-only: on web/PC the
|
||
// listeners aren't attached at all.
|
||
//
|
||
// Peek path: drag down from `closed` (not pinned) 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`.
|
||
//
|
||
// Pin path: drag UP from `closed` (not pinned) tracks the finger 1:1
|
||
// with a hard clamp at -PIN_TRAVEL_PX (curtain stops flush above the
|
||
// tabs row — never enters the system-tray safe-top zone). On release
|
||
// past `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` flips `pinned = true`.
|
||
// Only reachable when the list has no scrollable content — with scroll
|
||
// the user's drag-up means «scroll list down» and the gesture defers
|
||
// to native (see the scroll-aware bail in `onTouchMove`).
|
||
//
|
||
// Unpin: NOT handled here. Once pinned, the list-bound gesture is
|
||
// inert in both directions — `useCurtainHandleGesture` (the handle at
|
||
// the top of the curtain) owns the unpin transition exclusively. A
|
||
// drag-down on the list while pinned used to unpin here, but that
|
||
// path was a confusing back-door: the handle is the canonical
|
||
// pin/unpin surface and the only one the user is taught.
|
||
//
|
||
// 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,
|
||
pinned,
|
||
setPinned,
|
||
setLiveDrag,
|
||
commit,
|
||
disabled,
|
||
}: 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;
|
||
// Same mirror trick for the global pinned overlay so the listener
|
||
// sees fresh values without re-binding on every render.
|
||
const pinnedRef = useRef<boolean>(pinned);
|
||
pinnedRef.current = pinned;
|
||
// Stable setter ref so the bound-once listener can call the latest
|
||
// setPinned without us needing to add it to the effect deps.
|
||
const setPinnedRef = useRef(setPinned);
|
||
setPinnedRef.current = setPinned;
|
||
|
||
useEffect(() => {
|
||
if (!isNativePlatform()) return undefined;
|
||
if (disabled) return undefined;
|
||
const list = scrollRef.current;
|
||
if (!list) return undefined;
|
||
|
||
let startX: number | null = null;
|
||
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;
|
||
startX = e.touches[0].clientX;
|
||
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) {
|
||
startX = null;
|
||
startY = null;
|
||
}
|
||
};
|
||
|
||
const onTouchMove = (e: TouchEvent) => {
|
||
if (e.touches.length !== 1) {
|
||
// Second finger landed mid-gesture — abort.
|
||
startX = null;
|
||
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)) {
|
||
startX = e.touches[0].clientX;
|
||
startY = e.touches[0].clientY;
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const delta = e.touches[0].clientY - startY;
|
||
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
||
const currentSnap = snapRef.current;
|
||
const currentPinned = pinnedRef.current;
|
||
|
||
// Resolve a direction once the finger crosses the dead-zone.
|
||
if (direction === null) {
|
||
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
|
||
// Horizontal-bail: if the finger crosses the dead-zone with
|
||
// |dx| strictly greater than |dy|, the user is swiping the
|
||
// mobile tab pager, not pulling the curtain. Drop our tracking
|
||
// state so the pager owns the gesture; ties still resolve to
|
||
// vertical (curtain) because pull-down is the more common
|
||
// intent on the listing surface.
|
||
if (Math.abs(deltaX) > Math.abs(delta)) {
|
||
startX = null;
|
||
startY = null;
|
||
direction = null;
|
||
return;
|
||
}
|
||
direction = delta > 0 ? 'down' : 'up';
|
||
|
||
// Direction guards:
|
||
// - pinned ⇒ list-bound gesture is inert. UP would push past
|
||
// y = -safe-top into the system-tray zone; DOWN would
|
||
// unpin, but unpin is handle-only territory (the dedicated
|
||
// drag-handle at the top of the curtain — see
|
||
// `useCurtainHandleGesture`). Both bail here.
|
||
// - peek ⇒ only UP (close); DOWN has nowhere lower to go.
|
||
// - form ⇒ only UP (close); DOWN reads as a list scroll.
|
||
// - closed + UP ⇒ pin path (only when list has no scroll).
|
||
// - closed + DOWN ⇒ peek path.
|
||
if (currentPinned) {
|
||
startX = null;
|
||
startY = null;
|
||
direction = null;
|
||
return;
|
||
}
|
||
// Below this point currentPinned is provably false (the pinned
|
||
// bail above returned). The `!currentPinned` qualifier on each
|
||
// remaining guard would be redundant, so it's dropped.
|
||
if (currentSnap === 'peek' && direction === 'down') {
|
||
startX = null;
|
||
startY = null;
|
||
direction = null;
|
||
return;
|
||
}
|
||
if (isFormSnap(currentSnap) && direction === 'down') {
|
||
startX = null;
|
||
startY = null;
|
||
direction = null;
|
||
return;
|
||
}
|
||
// Pin from the LIST is allowed only when the list has nothing
|
||
// to scroll (single-screen chat list). With scrollable content,
|
||
// drag-up at `scrollTop === 0` is the user's natural «scroll
|
||
// list down to reveal more rows» gesture and hijacking it for
|
||
// pin (and the resulting preventDefault) leaves the user
|
||
// staring at a frozen list. Pinning a scrollable list goes
|
||
// through the dedicated handle at the top of the curtain
|
||
// instead (see `useCurtainHandleGesture`). Single-screen lists
|
||
// keep the drag-from-anywhere pin path so the original UX is
|
||
// preserved when there's no list-scroll to compete with.
|
||
if (
|
||
currentSnap === 'closed' &&
|
||
direction === 'up' &&
|
||
list.scrollHeight > list.clientHeight + 1
|
||
) {
|
||
startX = null;
|
||
startY = null;
|
||
direction = null;
|
||
return;
|
||
}
|
||
}
|
||
|
||
engaged = true;
|
||
e.preventDefault();
|
||
|
||
// currentPinned is false here (the pinned bail above returned).
|
||
// List-bound pin/unpin doesn't exist as a path on this hook —
|
||
// unpin is exclusively the handle hook's responsibility, and
|
||
// pin from the list is only reachable in the no-scroll branch
|
||
// below.
|
||
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 if (currentSnap === 'closed' && direction === 'up') {
|
||
// Pin from list (only reachable when the list has no scroll
|
||
// — see the scroll-aware bail above). 1:1 finger ↔ curtain
|
||
// so the user gets the same direct feel as the dedicated
|
||
// handle. Clamped at -PIN_TRAVEL_PX so the curtain never
|
||
// enters the system-tray safe-top zone (see geometry's
|
||
// «pinned visual contract»).
|
||
lastDelta = Math.max(-PIN_TRAVEL_PX, 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;
|
||
const currentPinned = pinnedRef.current;
|
||
let next: CurtainSnap = currentSnap;
|
||
let nextPinned = currentPinned;
|
||
|
||
// currentPinned is false here in any engaged gesture (the
|
||
// direction guard bails when pinned). The unpin commit branch
|
||
// that used to live here moved to `useCurtainHandleGesture`.
|
||
if (isFormSnap(currentSnap)) {
|
||
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
||
next = 'closed';
|
||
}
|
||
} else if (currentSnap === 'closed' && direction === 'up') {
|
||
// Pin commit: drag UP past ≥ PIN_COMMIT_THRESHOLD of the full
|
||
// travel flips pinned → true. The user emphasised this must
|
||
// require «дотянул прям до самого верха» — high threshold
|
||
// matches that intent.
|
||
const progress = -lastDelta / PIN_TRAVEL_PX;
|
||
if (progress >= PIN_COMMIT_THRESHOLD) {
|
||
nextPinned = true;
|
||
}
|
||
} 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 (nextPinned !== currentPinned) {
|
||
// setPinned() resets liveDragPx + isDragging in the same
|
||
// batched update — React renders the curtain at the new
|
||
// pinned-derived top with the snap transition re-enabled.
|
||
setPinnedRef.current(nextPinned);
|
||
} else 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);
|
||
}
|
||
|
||
startX = null;
|
||
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);
|
||
startX = null;
|
||
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`, `pinned` and `setPinned` are mirrored via the refs above
|
||
// so the listener (bound once per disabled-flip) reads fresh values
|
||
// without re-attaching every render. `disabled` is the only signal
|
||
// that needs to tear the listeners down — it goes into the deps.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [scrollRef, setLiveDrag, commit, disabled]);
|
||
}
|