feat(stream-header): free-range curtain drag through full pin↔closed↔peek range with bottomPinned-aware body bail and native-only handle
This commit is contained in:
parent
ab283e9788
commit
8fb885df1b
4 changed files with 263 additions and 136 deletions
|
|
@ -129,20 +129,24 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
// Two parallel curtain-gesture surfaces:
|
// Two parallel curtain-gesture surfaces:
|
||||||
//
|
//
|
||||||
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
|
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
|
||||||
// at the top of the curtain. Crisp 1:1 finger ↔ curtain on
|
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
|
||||||
// every transition (pin, unpin, peek, close-peek, form-close).
|
// closed the gesture is a free-range drag spanning pin↔closed↔
|
||||||
|
// peek in one motion (`closed-free`); other snaps drive single-
|
||||||
|
// destination transitions (unpin / close-peek / form-close).
|
||||||
// Engages regardless of whether the chat list is scrollable —
|
// Engages regardless of whether the chat list is scrollable —
|
||||||
// the handle is a distinct surface and never competes with list
|
// the handle is a distinct surface and never competes with list
|
||||||
// scroll.
|
// scroll. Only rendered on native (`isNativePlatform()`).
|
||||||
//
|
//
|
||||||
// * `useCurtainBodyGesture` — anywhere on the curtain body
|
// * `useCurtainBodyGesture` — anywhere on the curtain body
|
||||||
// OUTSIDE the handle (chat list, empty-state placeholder,
|
// OUTSIDE the handle (chat list, empty-state placeholder).
|
||||||
// bottom-pinned row). Rubber-banded (0.65) on every transition
|
// Rubber-banded (0.65) for all transitions, so the body drag
|
||||||
// so the body drag reads as physically «heavier» than the
|
// reads as physically «heavier» than the handle's crisp pull.
|
||||||
// handle's crisp pull. Engages ONLY when the chat list has no
|
// Engages only when the chat list has no scrollable content;
|
||||||
// scrollable content — long lists keep native vertical scroll;
|
// additionally bails on touches that start inside the bottom-
|
||||||
// short / empty lists let the user pull the curtain «from
|
// pinned slot (DirectSelfRow / WorkspaceFooter / ChannelCreate
|
||||||
// anywhere».
|
// have their own drag-to-open bottom sheets) and on touches
|
||||||
|
// that start while pinned (unpin is HANDLE-only — the user has
|
||||||
|
// to grab the dedicated affordance to release the lock).
|
||||||
//
|
//
|
||||||
// Both hooks share `handleVisual` (mirrors desktop
|
// Both hooks share `handleVisual` (mirrors desktop
|
||||||
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
|
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
|
||||||
|
|
@ -154,6 +158,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
// visual.
|
// visual.
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
const curtainRef = useRef<HTMLDivElement>(null);
|
const curtainRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bottomPinnedRef = useRef<HTMLDivElement>(null);
|
||||||
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
|
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
|
||||||
dragging: false,
|
dragging: false,
|
||||||
atCommit: false,
|
atCommit: false,
|
||||||
|
|
@ -171,6 +176,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
useCurtainBodyGesture({
|
useCurtainBodyGesture({
|
||||||
curtainRef,
|
curtainRef,
|
||||||
handleRef,
|
handleRef,
|
||||||
|
bottomPinnedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
snap: curtain.snap,
|
snap: curtain.snap,
|
||||||
pinned: curtain.pinned,
|
pinned: curtain.pinned,
|
||||||
|
|
@ -448,14 +454,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
}}
|
}}
|
||||||
onTransitionEnd={onCurtainTransitionEnd}
|
onTransitionEnd={onCurtainTransitionEnd}
|
||||||
>
|
>
|
||||||
{/* Drag handle (native-only behaviour, but rendered on all
|
{/* Drag handle — native-only. On web (desktop browsers,
|
||||||
platforms so the layout stays identical — the gesture hook
|
Electron) the curtain has no interactive snap states, so
|
||||||
short-circuits off-native). Hosts the entire curtain
|
the handle would be pure decoration with no behaviour
|
||||||
gesture surface — pin, unpin, peek, close-peek and
|
behind it; rendering it conditionally drops the 32 px
|
||||||
form-close all bind here, leaving the chat list to native
|
grabber strip on those surfaces and lets the chat list
|
||||||
scroll. Stays mounted across snap transitions so the
|
sit flush against the curtain's rounded top.
|
||||||
gesture surface is always reachable when there is one to
|
|
||||||
make.
|
On native the handle hosts the authoritative curtain
|
||||||
|
gesture (pin / unpin / peek / close-peek / form-close)
|
||||||
|
and stays mounted across snap transitions so the gesture
|
||||||
|
surface is always reachable when there is one to make.
|
||||||
|
|
||||||
`data-dragging` / `data-at-commit` mirror the desktop
|
`data-dragging` / `data-at-commit` mirror the desktop
|
||||||
`PageNavResizeHandle`: CSS selectors on `handleBar` light
|
`PageNavResizeHandle`: CSS selectors on `handleBar` light
|
||||||
|
|
@ -463,15 +472,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
Both attrs are emitted/cleared only via React state set by
|
Both attrs are emitted/cleared only via React state set by
|
||||||
the gesture hook (dedup'd), so the handle visual updates
|
the gesture hook (dedup'd), so the handle visual updates
|
||||||
without slamming the DOM on every touchmove. */}
|
without slamming the DOM on every touchmove. */}
|
||||||
<div
|
{isNativePlatform() && (
|
||||||
ref={handleRef}
|
<div
|
||||||
className={css.handle}
|
ref={handleRef}
|
||||||
data-dragging={handleVisual.dragging || undefined}
|
className={css.handle}
|
||||||
data-at-commit={handleVisual.atCommit || undefined}
|
data-dragging={handleVisual.dragging || undefined}
|
||||||
aria-hidden
|
data-at-commit={handleVisual.atCommit || undefined}
|
||||||
>
|
aria-hidden
|
||||||
<div className={css.handleBar} />
|
>
|
||||||
</div>
|
<div className={css.handleBar} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
|
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
|
||||||
kept mounted across snaps so the curtain reads as a self-
|
kept mounted across snaps so the curtain reads as a self-
|
||||||
|
|
@ -482,6 +493,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
`keyboardOpen` effect above for the rationale). */}
|
`keyboardOpen` effect above for the rationale). */}
|
||||||
{bottomPinned && (
|
{bottomPinned && (
|
||||||
<div
|
<div
|
||||||
|
ref={bottomPinnedRef}
|
||||||
className={css.bottomPinnedSlot}
|
className={css.bottomPinnedSlot}
|
||||||
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
|
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -97,30 +97,43 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
|
||||||
// release for the snap to flip. Anything shorter reads as accidental
|
// release for the snap to flip. Anything shorter reads as accidental
|
||||||
// and springs back to the previous resting snap.
|
// and springs back to the previous resting snap.
|
||||||
//
|
//
|
||||||
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin —
|
// On the handle the up direction is 1:1 with no upper clamp (the
|
||||||
// see `useCurtainHandleGesture`), the committing finger pull is
|
// «closed-free» transition spans the full pin↔closed↔peek range in
|
||||||
|
// one gesture and the curtain follows the finger off-screen freely);
|
||||||
|
// the committing curtain DISPLACEMENT is still
|
||||||
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
|
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
|
||||||
// the curtain across the full tabs-row height». The anti-accidental
|
// the curtain across the full tabs-row height». On the body the same
|
||||||
// gate is provided by the dedicated handle hit-zone (intentional
|
// displacement is reached with a longer finger pull because the body
|
||||||
// surface) — the chat list under the curtain is left to native
|
// path is rubber-banded (×0.65).
|
||||||
// scroll and never engages a pin path, so there's no scroll-vs-pin
|
//
|
||||||
// ambiguity to disambiguate.
|
// Unpin is the one exception that keeps a hard ±PIN_TRAVEL_PX clamp:
|
||||||
|
// the handle-only contract makes it a deliberate full-travel pull,
|
||||||
|
// so we don't want the finger overshooting past closed into peek
|
||||||
|
// territory mid-gesture.
|
||||||
export const PIN_COMMIT_THRESHOLD = 0.95;
|
export const PIN_COMMIT_THRESHOLD = 0.95;
|
||||||
|
|
||||||
// Drag-handle hit-zone at the top of the curtain. The handle is the
|
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
|
||||||
// AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and
|
// handle is rendered only when `isNativePlatform()` is true (see
|
||||||
// form-close all bind here with 1:1 finger ↔ curtain tracking, no
|
// StreamHeader.tsx) — on web (desktop / Electron) the curtain has
|
||||||
// matter whether the chat list inside the curtain is scrollable. See
|
// no interactive snap states, so the handle would be pure
|
||||||
// `useCurtainHandleGesture` for the full state machine.
|
// decoration and is omitted entirely.
|
||||||
|
//
|
||||||
|
// On native the handle is the AUTHORITATIVE gesture surface —
|
||||||
|
// closed-free / unpin / close-peek / form-close all bind here with
|
||||||
|
// 1:1 finger ↔ curtain tracking, no matter whether the chat list
|
||||||
|
// inside the curtain is scrollable. See `useCurtainHandleGesture`
|
||||||
|
// for the full state machine.
|
||||||
//
|
//
|
||||||
// A parallel `useCurtainBodyGesture` bound to the curtain's body
|
// A parallel `useCurtainBodyGesture` bound to the curtain's body
|
||||||
// (everything below the handle) handles drag from anywhere on the
|
// handles drag from anywhere on the card, but only when the inner
|
||||||
// card, but only when the inner chat list has no scrollable content
|
// chat list has no scrollable content AND the curtain isn't pinned
|
||||||
// — its dynamics are rubber-banded so the body drag reads as
|
// (unpin is handle-only). Its dynamics are rubber-banded so the
|
||||||
// physically «heavier» than the handle's crisp pull.
|
// body drag reads as physically «heavier» than the handle's crisp
|
||||||
|
// pull.
|
||||||
//
|
//
|
||||||
// Size: 32 px tall — enough touch target to land on comfortably with
|
// Size: 32 px tall — enough touch target to land on comfortably with
|
||||||
// a thumb (the visible grabber pill inside is much smaller, see
|
// a thumb (the visible grabber pill inside is much smaller, see
|
||||||
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the
|
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the
|
||||||
// equivalent placeholder) starts 32 px below the curtain's top edge.
|
// equivalent placeholder) starts 32 px below the curtain's top edge
|
||||||
|
// on native; on web the list sits flush at the curtain's top.
|
||||||
export const HANDLE_HEIGHT_PX = 32;
|
export const HANDLE_HEIGHT_PX = 32;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ type Args = {
|
||||||
// when the touch starts inside the handle's hit-zone (the handle
|
// when the touch starts inside the handle's hit-zone (the handle
|
||||||
// hook has already armed for that touch).
|
// hook has already armed for that touch).
|
||||||
handleRef: MutableRefObject<HTMLDivElement | null>;
|
handleRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
// The `bottomPinned` slot at the bottom of the curtain (hosts
|
||||||
|
// DirectSelfRow, ChannelCreateRow, WorkspaceFooter). These rows
|
||||||
|
// open their own bottom sheets via vertical drag, so a touch that
|
||||||
|
// starts there must NOT engage the curtain body — otherwise the
|
||||||
|
// user's «pull settings up» gesture would also pin the curtain
|
||||||
|
// and the two motions would visually fight. `null` is fine (the
|
||||||
|
// surface has no bottomPinned content); the contains() check is
|
||||||
|
// optional-chained.
|
||||||
|
bottomPinnedRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
// Scroll viewport of the chat list inside the curtain. The body
|
// Scroll viewport of the chat list inside the curtain. The body
|
||||||
// gesture engages only when this element is NOT scrollable
|
// gesture engages only when this element is NOT scrollable
|
||||||
// (scrollHeight ≤ clientHeight + 1): on long lists the user's
|
// (scrollHeight ≤ clientHeight + 1): on long lists the user's
|
||||||
|
|
@ -86,9 +95,17 @@ type Args = {
|
||||||
// Skip the scrollable-bail in that case — the body's visible area is
|
// Skip the scrollable-bail in that case — the body's visible area is
|
||||||
// the strip BELOW the form, and a drag there is unambiguously a
|
// the strip BELOW the form, and a drag there is unambiguously a
|
||||||
// form-close intent (the only valid transition from form-* snap).
|
// form-close intent (the only valid transition from form-* snap).
|
||||||
|
//
|
||||||
|
// Pinned override: the body gesture is INERT while the curtain is
|
||||||
|
// pinned. Unpin is exclusively the handle's contract — the user has
|
||||||
|
// to grab the dedicated pin-handle to release the lock, so an
|
||||||
|
// accidental drag anywhere on the visible card doesn't undo it. We
|
||||||
|
// bail at touchstart so no listener side-effects (preventDefault,
|
||||||
|
// liveDrag emit, …) can fire either.
|
||||||
export function useCurtainBodyGesture({
|
export function useCurtainBodyGesture({
|
||||||
curtainRef,
|
curtainRef,
|
||||||
handleRef,
|
handleRef,
|
||||||
|
bottomPinnedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
snap,
|
snap,
|
||||||
pinned,
|
pinned,
|
||||||
|
|
@ -134,11 +151,20 @@ export function useCurtainBodyGesture({
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent) => {
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
|
// Pinned bail — handle owns unpin exclusively. See the «Pinned
|
||||||
|
// override» note above the hook for the rationale.
|
||||||
|
if (pinnedRef.current) return;
|
||||||
// Hand off to the handle hook if the touch starts inside the
|
// Hand off to the handle hook if the touch starts inside the
|
||||||
// handle's 32 px hit-zone — the handle's own listener has
|
// handle's 32 px hit-zone — the handle's own listener has
|
||||||
// already armed for this touch.
|
// already armed for this touch.
|
||||||
const target = e.target as Node | null;
|
const target = e.target as Node | null;
|
||||||
if (target && handleRef.current?.contains(target)) return;
|
if (target && handleRef.current?.contains(target)) return;
|
||||||
|
// Hand off to the bottomPinned region (DirectSelfRow,
|
||||||
|
// WorkspaceFooter, ChannelCreateRow). Those rows host their
|
||||||
|
// own drag-to-open bottom sheets — engaging the curtain
|
||||||
|
// gesture here would pin the curtain in parallel with the
|
||||||
|
// sheet opening, and the two motions would visually fight.
|
||||||
|
if (target && bottomPinnedRef.current?.contains(target)) return;
|
||||||
// Scroll-aware bail: leave a scrollable chat list to its native
|
// Scroll-aware bail: leave a scrollable chat list to its native
|
||||||
// vertical scroll. Skipped in form-* snaps because the visible
|
// vertical scroll. Skipped in form-* snaps because the visible
|
||||||
// body area there is the strip BELOW the form (where the list
|
// body area there is the strip BELOW the form (where the list
|
||||||
|
|
@ -206,24 +232,26 @@ export function useCurtainBodyGesture({
|
||||||
// only the finger pull needed differs.
|
// only the finger pull needed differs.
|
||||||
let atCommit = false;
|
let atCommit = false;
|
||||||
switch (transition) {
|
switch (transition) {
|
||||||
case 'pin':
|
case 'closed-free':
|
||||||
// Rubber-banded up, clamped at the safe-top edge.
|
// Rubber-banded free-range drag spanning pin↔closed↔peek
|
||||||
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND));
|
// in one motion. NO clamps either side — the curtain
|
||||||
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
|
// follows the finger off-screen upward and continuously
|
||||||
break;
|
// into peek territory downward. Direction-aware atCommit
|
||||||
case 'unpin':
|
// shows the right commit feedback for whichever side the
|
||||||
// Rubber-banded down, clamped at the closed-resting edge.
|
// user is leaning into. Mirrors the handle's `closed-free`
|
||||||
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND));
|
// but with 0.65× displacement so the body drag reads as
|
||||||
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
|
// physically «heavier».
|
||||||
break;
|
|
||||||
case 'peek':
|
|
||||||
// Rubber-banded down. Bounds come from the direction guard
|
|
||||||
// above plus the snap clamp on touchend, so no extra clamp —
|
|
||||||
// matches the original list-bound peek feel.
|
|
||||||
lastDelta = delta * RUBBER_BAND;
|
lastDelta = delta * RUBBER_BAND;
|
||||||
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
atCommit =
|
||||||
|
lastDelta <= 0
|
||||||
|
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
|
||||||
|
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
||||||
break;
|
break;
|
||||||
case 'close-peek':
|
case 'close-peek':
|
||||||
|
// Rubber-banded up. No clamp either side — matches the
|
||||||
|
// original list-bound peek feel; a downward jitter past the
|
||||||
|
// peek snap is visually negligible against the rubber-band
|
||||||
|
// damping.
|
||||||
lastDelta = delta * RUBBER_BAND;
|
lastDelta = delta * RUBBER_BAND;
|
||||||
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
||||||
break;
|
break;
|
||||||
|
|
@ -233,6 +261,11 @@ export function useCurtainBodyGesture({
|
||||||
lastDelta = Math.min(0, delta * RUBBER_BAND);
|
lastDelta = Math.min(0, delta * RUBBER_BAND);
|
||||||
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
||||||
break;
|
break;
|
||||||
|
// `pinned-free` is intentionally absent — the pinned-bail
|
||||||
|
// at touchstart prevents the body hook from ever resolving
|
||||||
|
// to it. If a future change exposes pinned-free on the
|
||||||
|
// body, add the dispatch alongside this default so the
|
||||||
|
// linter keeps the switch exhaustive.
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -249,22 +282,13 @@ export function useCurtainBodyGesture({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (transition) {
|
switch (transition) {
|
||||||
case 'pin':
|
case 'closed-free':
|
||||||
|
// Direction-aware commit, sign-exclusive: pin wins UP-side,
|
||||||
|
// peek wins DOWN-side, below both thresholds spring back to
|
||||||
|
// closed.
|
||||||
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
||||||
setPinnedRef.current(true);
|
setPinnedRef.current(true);
|
||||||
} else {
|
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
||||||
setLiveDrag(0, false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'unpin':
|
|
||||||
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
|
||||||
setPinnedRef.current(false);
|
|
||||||
} else {
|
|
||||||
setLiveDrag(0, false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'peek':
|
|
||||||
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
|
||||||
commitRef.current('peek');
|
commitRef.current('peek');
|
||||||
} else {
|
} else {
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
|
|
@ -322,5 +346,5 @@ export function useCurtainBodyGesture({
|
||||||
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
|
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
|
||||||
// to tear listeners down — it's the sole effect dep.
|
// to tear listeners down — it's the sole effect dep.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [curtainRef, handleRef, scrollRef, setLiveDrag, disabled]);
|
}, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,30 @@ type Args = {
|
||||||
// on the curtain body) decide how raw finger displacement translates
|
// on the curtain body) decide how raw finger displacement translates
|
||||||
// into curtain motion — see `onTouchMove` here for the 1:1 branches
|
// into curtain motion — see `onTouchMove` here for the 1:1 branches
|
||||||
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
|
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
|
||||||
export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-close';
|
//
|
||||||
|
// `closed-free` is the single free-range transition that spans the
|
||||||
|
// full pin↔closed↔peek vertical range in one gesture. From the closed
|
||||||
|
// snap, neither direction is locked at the dead-zone: the user can
|
||||||
|
// drag up past the safe-top zone OR down through the chip area in
|
||||||
|
// one motion, and the release decides pin / peek / snap-back based
|
||||||
|
// on the final position. The earlier pair of one-shot `pin` and
|
||||||
|
// `peek` transitions used a hard «gate» at the start point (each
|
||||||
|
// direction was clamped to one side of 0 once the dead-zone resolved
|
||||||
|
// the direction) and the user reported this as a regression — drag
|
||||||
|
// up, then back down, ran into an invisible wall at the closed
|
||||||
|
// position before peek could engage.
|
||||||
|
//
|
||||||
|
// `pinned-free` is the symmetric free-range transition for the
|
||||||
|
// pinned overlay: from pinned + drag DOWN the curtain follows the
|
||||||
|
// finger all the way through closed into peek territory in one
|
||||||
|
// motion. On release, peek wins if the finger crossed the absolute
|
||||||
|
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX —
|
||||||
|
// the same visual point peek commits at from closed-free), unpin
|
||||||
|
// wins if at least the unpin threshold was reached, otherwise snap
|
||||||
|
// back to pinned. UP is no-op (no destination above pinned). Only
|
||||||
|
// the handle resolves to `pinned-free` — the body gesture bails at
|
||||||
|
// touchstart while pinned so unpin remains a deliberate handle pull.
|
||||||
|
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
|
||||||
|
|
||||||
// Decide which transition the gesture arms based on the snap state
|
// Decide which transition the gesture arms based on the snap state
|
||||||
// at direction-resolution time and the finger direction. `null` means
|
// at direction-resolution time and the finger direction. `null` means
|
||||||
|
|
@ -63,10 +86,17 @@ export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-
|
||||||
// owns the touch.
|
// owns the touch.
|
||||||
//
|
//
|
||||||
// Direction guards encoded here:
|
// Direction guards encoded here:
|
||||||
// * pinned + UP → no-op (would push the curtain past safe-top).
|
// * pinned + UP → no-op (would push the curtain past safe-top
|
||||||
// * pinned + DOWN → unpin.
|
// on commit — no destination above pinned).
|
||||||
// * closed + UP → pin.
|
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body
|
||||||
// * closed + DOWN → peek.
|
// hook bails entirely while pinned so unpin /
|
||||||
|
// peek-from-pinned stays a deliberate handle
|
||||||
|
// pull. See
|
||||||
|
// `useCurtainBodyGesture::onTouchStart`).
|
||||||
|
// * closed (any) → closed-free (single transition spanning the
|
||||||
|
// whole pin↔closed↔peek range; direction at
|
||||||
|
// the dead-zone matters only for the
|
||||||
|
// horizontal-bail check).
|
||||||
// * peek + UP → close-peek (retreat to closed).
|
// * peek + UP → close-peek (retreat to closed).
|
||||||
// * peek + DOWN → no-op (nothing lower to reveal).
|
// * peek + DOWN → no-op (nothing lower to reveal).
|
||||||
// * form-* + UP → form-close.
|
// * form-* + UP → form-close.
|
||||||
|
|
@ -76,8 +106,8 @@ export function resolveCurtainTransition(
|
||||||
pinned: boolean,
|
pinned: boolean,
|
||||||
direction: 'up' | 'down'
|
direction: 'up' | 'down'
|
||||||
): CurtainTransition | null {
|
): CurtainTransition | null {
|
||||||
if (pinned) return direction === 'down' ? 'unpin' : null;
|
if (pinned) return direction === 'down' ? 'pinned-free' : null;
|
||||||
if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek';
|
if (snap === 'closed') return 'closed-free';
|
||||||
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
|
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
|
||||||
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
|
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -88,30 +118,52 @@ export function resolveCurtainTransition(
|
||||||
// desktop.
|
// desktop.
|
||||||
//
|
//
|
||||||
// The handle is the «authoritative» gesture surface — it owns every
|
// The handle is the «authoritative» gesture surface — it owns every
|
||||||
// transition (pin, unpin, peek, close-peek, form-close) with crisp
|
// transition (closed-free, pinned-free, close-peek, form-close)
|
||||||
// 1:1 finger ↔ curtain tracking regardless of whether the chat list
|
// with crisp 1:1 finger ↔ curtain tracking regardless of whether
|
||||||
// inside the curtain is scrollable. The curtain BODY has a parallel
|
// the chat list inside the curtain is scrollable. The curtain BODY
|
||||||
// gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that
|
// has a parallel gesture (`useCurtainBodyGesture`) with rubber-
|
||||||
// only engages when the body's chat list has no scrollable content —
|
// banded dynamics that only engages when the body's chat list has
|
||||||
// so the user can pull the curtain «from anywhere» on empty / short
|
// no scrollable content — so the user can pull the curtain «from
|
||||||
// lists but a real list-scroll is never hijacked under their finger.
|
// anywhere» on empty / short lists but a real list-scroll is never
|
||||||
|
// hijacked under their finger. The body is also fully inert while
|
||||||
|
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
|
||||||
|
// handle pull.
|
||||||
// History note: an earlier `useCurtainGesture` bound the peek /
|
// History note: an earlier `useCurtainGesture` bound the peek /
|
||||||
// form-close paths to the list scroll viewport directly. That coupling
|
// form-close paths to the list scroll viewport directly. That coupling
|
||||||
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
|
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
|
||||||
// down at scrollTop=0 hijacks for peek» bugs and was removed when
|
// down at scrollTop=0 hijacks for peek» bugs and was removed when
|
||||||
// pin / unpin moved here.
|
// pin / unpin moved here.
|
||||||
//
|
//
|
||||||
// All five transitions track the finger 1:1, clamped at the relevant
|
// Per-transition dynamics — all track the finger 1:1, but the clamp
|
||||||
// snap edge so jitter past the destination doesn't visually overshoot:
|
// shapes differ to keep on-screen motion sensible while preserving
|
||||||
// * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at
|
// the «drag up off-screen from anywhere» feel the user explicitly
|
||||||
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX
|
// asked for:
|
||||||
// («дотянул прям до самого верха»).
|
// * closed-free — NO clamps either side. Finger goes off-
|
||||||
// * peek / close-peek — clamp ±PEEK_TRAVEL_PX, commit at
|
// screen up → curtain follows past safe-top;
|
||||||
|
// finger crosses back below the start point →
|
||||||
|
// curtain continues into peek territory in
|
||||||
|
// the same gesture. Direction-aware commit
|
||||||
|
// on release: pin if pulled UP past
|
||||||
|
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek
|
||||||
|
// if pulled DOWN past COMMIT_THRESHOLD ×
|
||||||
|
// PEEK_TRAVEL_PX, else snap back to closed.
|
||||||
|
// * pinned-free — DOWN-only free-range drag from pinned.
|
||||||
|
// Clamped at 0 below (no destination above
|
||||||
|
// pinned), NO upper clamp — the finger can
|
||||||
|
// carry the curtain through closed into
|
||||||
|
// peek territory in one motion. Release
|
||||||
|
// decides peek (lastDelta ≥ PIN_TRAVEL_PX +
|
||||||
|
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin
|
||||||
|
// (lastDelta ≥ PIN_COMMIT_THRESHOLD ×
|
||||||
|
// PIN_TRAVEL_PX), or snap back to pinned.
|
||||||
|
// * close-peek — capped at 0 below (no transition lower
|
||||||
|
// than peek), NO upper clamp (drag past
|
||||||
|
// closed into safe-top freely). Commit at
|
||||||
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
|
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
|
||||||
// * form-close — capped at 0 so a downward jitter can't push
|
// * form-close — capped at 0 so a downward jitter can't
|
||||||
// the curtain below its form-snap position.
|
// push the curtain below its form-snap top,
|
||||||
// Commit at ACTIVE_CLOSE_THRESHOLD_PX
|
// NO upper clamp. Commit at
|
||||||
// (absolute distance, not a fraction).
|
// ACTIVE_CLOSE_THRESHOLD_PX (absolute).
|
||||||
//
|
//
|
||||||
// Handle visual: emitHandle(true, atCommit) fires on every transition
|
// Handle visual: emitHandle(true, atCommit) fires on every transition
|
||||||
// during touchmove so the grabber pill animates Primary-blue +
|
// during touchmove so the grabber pill animates Primary-blue +
|
||||||
|
|
@ -223,36 +275,47 @@ export function useCurtainHandleGesture({
|
||||||
engaged = true;
|
engaged = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Clamp / rubber-band the raw finger delta into the live curtain
|
// Clamp the raw finger delta into the live curtain displacement
|
||||||
// displacement (`lastDelta`). Stored separately because the
|
// (`lastDelta`). Stored separately because the commit math on
|
||||||
// commit math on release needs the same value the curtain was
|
// release needs the same value the curtain was visually showing.
|
||||||
// visually showing.
|
|
||||||
let atCommit = false;
|
let atCommit = false;
|
||||||
switch (transition) {
|
switch (transition) {
|
||||||
case 'pin':
|
case 'closed-free':
|
||||||
// 1:1 up, clamped so the curtain doesn't enter the
|
// Single free-range drag spanning pin↔closed↔peek. 1:1 with
|
||||||
// system-tray safe-top zone.
|
// NO clamps either side: the curtain follows the finger off-
|
||||||
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
|
// screen upward (past safe-top) and continuously into peek
|
||||||
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
|
// territory downward in the same gesture. The release decides
|
||||||
|
// pin / peek / snap-back from the final lastDelta.
|
||||||
|
lastDelta = delta;
|
||||||
|
// Direction-aware atCommit so the grabber pill stretches
|
||||||
|
// whichever way the user is committing. Pin and peek are
|
||||||
|
// sign-exclusive (one branch can't fire simultaneously with
|
||||||
|
// the other) so a simple ternary on `lastDelta` suffices.
|
||||||
|
atCommit =
|
||||||
|
lastDelta <= 0
|
||||||
|
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
|
||||||
|
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
||||||
break;
|
break;
|
||||||
case 'unpin':
|
case 'pinned-free':
|
||||||
// 1:1 down, clamped so the curtain doesn't descend past its
|
// 1:1 down from pinned. Clamped at 0 below (a downward
|
||||||
// `closed` resting top during the drag.
|
// jitter past the start mustn't push the curtain into
|
||||||
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta));
|
// safe-top — there's no destination above pinned), NO
|
||||||
|
// upper clamp — the curtain follows the finger through
|
||||||
|
// closed into peek territory in one motion.
|
||||||
|
lastDelta = Math.max(0, delta);
|
||||||
|
// atCommit fires as soon as ANY commit qualifies (the
|
||||||
|
// grabber pill stretches to signal «release works here»);
|
||||||
|
// it stays true past the unpin threshold all the way
|
||||||
|
// through peek, since both are valid landing zones.
|
||||||
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
|
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
|
||||||
break;
|
break;
|
||||||
case 'peek':
|
|
||||||
// 1:1 down, clamped at +PEEK_TRAVEL_PX so a long pull past
|
|
||||||
// the peek snap doesn't visually overshoot. Math.max(0,…)
|
|
||||||
// guards against a momentary direction reversal nudging the
|
|
||||||
// curtain above the closed origin while transition is still
|
|
||||||
// armed for «down».
|
|
||||||
lastDelta = Math.max(0, Math.min(PEEK_TRAVEL_PX, delta));
|
|
||||||
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
|
||||||
break;
|
|
||||||
case 'close-peek':
|
case 'close-peek':
|
||||||
// 1:1 up; delta is negative. Symmetric clamp to peek above.
|
// 1:1 up; delta is negative. Lower-capped at 0 (a downward
|
||||||
lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta));
|
// jitter shouldn't push past the peek snap), NO upper clamp
|
||||||
|
// — the curtain follows the finger off-screen freely in the
|
||||||
|
// safe-top direction, matching the «drag up off-screen from
|
||||||
|
// anywhere» expectation.
|
||||||
|
lastDelta = Math.min(0, delta);
|
||||||
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
||||||
break;
|
break;
|
||||||
case 'form-close':
|
case 'form-close':
|
||||||
|
|
@ -285,27 +348,42 @@ export function useCurtainHandleGesture({
|
||||||
// transition re-enabled. Non-commit paths drop the live drag back
|
// transition re-enabled. Non-commit paths drop the live drag back
|
||||||
// to 0 with transition active so the curtain springs back.
|
// to 0 with transition active so the curtain springs back.
|
||||||
switch (transition) {
|
switch (transition) {
|
||||||
case 'pin':
|
case 'closed-free':
|
||||||
|
// Direction-aware commit from the free-range drag. Pin
|
||||||
|
// wins over peek if both somehow qualified (sign-exclusive
|
||||||
|
// in practice — lastDelta can't be simultaneously <0 and
|
||||||
|
// >0). Below either threshold, spring back to closed.
|
||||||
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
||||||
setPinnedRef.current(true);
|
setPinnedRef.current(true);
|
||||||
} else {
|
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
||||||
setLiveDrag(0, false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'unpin':
|
|
||||||
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
|
||||||
setPinnedRef.current(false);
|
|
||||||
} else {
|
|
||||||
setLiveDrag(0, false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'peek':
|
|
||||||
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
|
||||||
commitRef.current('peek');
|
commitRef.current('peek');
|
||||||
} else {
|
} else {
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'pinned-free':
|
||||||
|
// Two-tier commit: peek wins if the finger crossed the
|
||||||
|
// absolute peek planka (matches the visual point peek
|
||||||
|
// commits at from closed-free — PIN_TRAVEL_PX to get to
|
||||||
|
// closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the
|
||||||
|
// chip area); otherwise unpin if at least the unpin
|
||||||
|
// threshold was reached; else snap back to pinned.
|
||||||
|
//
|
||||||
|
// The peek branch MUST clear `pinned` before committing
|
||||||
|
// the snap. The curtain's resting top is
|
||||||
|
// `pinned ? 0 : snapTopPx(snap)` — so commit('peek')
|
||||||
|
// alone would set snap='peek' yet leave the curtain
|
||||||
|
// visually at top=0 (the pin overlay wins). Both updates
|
||||||
|
// batch into one render inside this touchend handler.
|
||||||
|
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * PEEK_TRAVEL_PX) {
|
||||||
|
setPinnedRef.current(false);
|
||||||
|
commitRef.current('peek');
|
||||||
|
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
||||||
|
setPinnedRef.current(false);
|
||||||
|
} else {
|
||||||
|
setLiveDrag(0, false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'close-peek':
|
case 'close-peek':
|
||||||
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
||||||
commitRef.current('closed');
|
commitRef.current('closed');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue