vojo/src/app/components/stream-header/useCurtainGesture.ts

341 lines
14 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 { 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]);
}