diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts index fa676c07..efb85b6d 100644 --- a/src/app/components/stream-header/StreamHeader.css.ts +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -7,6 +7,7 @@ import { CURTAIN_RADIUS_PX, CURTAIN_SNAP_EASING, CURTAIN_SNAP_MS, + HANDLE_HEIGHT_PX, TABS_ROW_PX, } from './geometry'; @@ -121,6 +122,64 @@ export const curtain = style({ willChange: 'top', }); +// Drag handle at the top of the curtain. Dedicated touch surface for +// the pin / unpin gesture so it doesn't compete with the chat list's +// vertical scroll. `touchAction: none` keeps the browser from claiming +// the gesture for native scroll heuristics — our `touchmove` listener +// in `useCurtainHandleGesture` drives every pixel of motion. +// +// Sits as the first flex child of the curtain so the list (or +// DirectEmpty / equivalent placeholder) takes the remaining space +// below it. `flexShrink: 0` locks the height so a long list doesn't +// squash the hit-zone. +export const handle = style({ + flexShrink: 0, + height: toRem(HANDLE_HEIGHT_PX), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + touchAction: 'none', +}); + +// Visual «grabber» pill centred inside `handle`. Semi-transparent +// foreground so the affordance reads as «draggable» without competing +// with content beneath. Pure decoration — the parent `handle` div +// captures the touch. +// +// State machine mirrors `PageNavResizeHandle` on desktop: a subtle +// resting state, a more prominent «being dragged» state, and an even +// more prominent «threshold reached, release to commit» state. The +// state-driving `data-dragging` / `data-at-commit` attributes live on +// the parent `handle` div (set by StreamHeader.tsx from the gesture +// hook). Transition durations match the desktop handle (140ms ease) +// so the two affordances feel related. +export const handleBar = style({ + width: toRem(40), + height: toRem(4), + borderRadius: toRem(2), + backgroundColor: color.Background.OnContainer, + opacity: 0.25, + pointerEvents: 'none', + transition: + 'opacity 140ms ease, width 140ms ease, height 140ms ease, background-color 140ms ease', + selectors: { + // Dragging but threshold not yet reached: highlight, slight grow. + '[data-dragging="true"] &': { + opacity: 0.55, + width: toRem(48), + backgroundColor: color.Primary.Main, + }, + // Threshold reached during drag: full stretch + opacity. Releasing + // here commits pin (or unpin). Reads as «yes, you're there». + '[data-dragging="true"][data-at-commit="true"] &': { + opacity: 0.9, + width: toRem(64), + height: toRem(5), + backgroundColor: color.Primary.Main, + }, + }, +}); + // Wrapper around `bottomPinned` inside the curtain. Anchored to the // curtain's flex-bottom by virtue of being the last child. The TSX // applies a `transform: translateY(keyboardH)` to this element when diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 89309273..600b2a7a 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -23,6 +24,7 @@ import { Segment } from './Segment'; import { Chip } from './Chip'; import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { useCurtainGesture } from './useCurtainGesture'; +import { useCurtainHandleGesture } from './useCurtainHandleGesture'; import { InlineNewChatForm } from './forms/InlineNewChatForm'; import { InlineRoomSearch } from './forms/InlineRoomSearch'; @@ -130,6 +132,35 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre disabled: gestureDisabled, }); + // Dedicated pin / unpin gesture on the drag handle at the top of + // the curtain. The handle exists specifically because the list-bound + // pin path conflicts with vertical list scroll at `scrollTop === 0` + // — see `useCurtainHandleGesture` and `useCurtainGesture`'s list- + // scroll guard for the full rationale. Same `disabled` gating as the + // list gesture so a bottom sheet or offscreen pager pane can't + // accidentally pin/unpin. + // + // `handleVisual` mirrors the desktop `PageNavResizeHandle` state + // machine: `dragging` lights up the grabber pill while the gesture + // is active; `atCommit` further stretches + brightens it once the + // user has crossed `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` so the + // release-to-confirm moment is unambiguous. The hook dedupes so this + // only re-renders when the visual state actually changes. + const handleRef = useRef(null); + const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({ + dragging: false, + atCommit: false, + }); + useCurtainHandleGesture({ + handleRef, + snap: curtain.snap, + pinned: curtain.pinned, + setPinned: curtain.setPinned, + setLiveDrag: curtain.setLiveDrag, + disabled: gestureDisabled, + setHandleState: setHandleVisual, + }); + const isActive = isFormSnap(curtain.snap); const openSearch = useCallback(() => curtain.open('search'), [curtain]); const openChat = useCallback(() => curtain.open('chat'), [curtain]); @@ -396,6 +427,29 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre }} onTransitionEnd={onCurtainTransitionEnd} > + {/* Drag handle (native-only behaviour, but rendered on all + platforms so the layout stays identical — the gesture hook + short-circuits off-native). Hosts the pin / unpin touch + listener and gives the user a visible affordance at the top + of the curtain. 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 + `PageNavResizeHandle`: CSS selectors on `handleBar` light + the pill up Primary-blue + stretch it when these flip. + Both attrs are emitted/cleared only via React state set by + the gesture hook (dedup'd), so the handle visual updates + without slamming the DOM on every touchmove. */} +
+
+
{children} {/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is kept mounted across snaps so the curtain reads as a self- diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index f2e49d86..4df5085c 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -92,28 +92,32 @@ export const ACTIVE_CLOSE_THRESHOLD_PX = 100; // the stage). export const PIN_TRAVEL_PX = TABS_ROW_PX; -// Rubber-band attenuation for the pin / unpin drag. Finger → curtain -// motion is scaled by this factor so the curtain feels heavier than -// the finger — a deliberate gate against accidental drag-up. -// -// User-confirmed intent: «вверх дотянуть нужно явно» — pinning must be -// an obvious sustained pull, not a casual flick. With 0.45 the user -// must drag ~142px of finger to slide the curtain across the full -// PIN_TRAVEL_PX (64px); combined with the very-high commit threshold -// below the minimum committing pull is ~135px, which is well above -// any plausible accidental scroll-attempt at scrollTop=0. -// -// Same factor applies to unpin (drag down from pinned) so both -// directions feel consistent — neither commits on a casual gesture. -export const PIN_RUBBER_BAND = 0.45; - // Commit threshold for pin / unpin. Tuned very high (≈95%) so the // user must drag the curtain almost-all-the-way to the cap before // release for the snap to flip. Anything shorter reads as accidental // and springs back to the previous resting snap. // -// The geometric meaning: after rubber-band, lastDelta ranges 0 … -// ±PIN_TRAVEL_PX (clamped). The commit fires when |lastDelta| ≥ -// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, i.e. the curtain visually -// reached at least 95% of the snap distance. +// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin +// — see `useCurtainGesture` and `useCurtainHandleGesture`), the +// committing finger pull is `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ +// 61 px — essentially «drag the curtain across the full tabs-row +// height». The anti-accidental gate that previously came from +// rubber-band amplification is now provided by the dedicated handle +// hit-zone (intentional surface) plus the list-bound scroll-aware +// bail (no list scroll = no scroll-up to confuse with pin). export const PIN_COMMIT_THRESHOLD = 0.95; + +// Drag-handle hit-zone at the top of the curtain. Hosts the pin / +// unpin gesture as a dedicated touch surface so it doesn't compete +// with the chat list's vertical scroll. Drag on this handle tracks +// the finger 1:1 (no rubber-band) — finger displacement equals +// curtain displacement. The list-bound gesture in +// `useCurtainGesture` still owns peek / form-close, plus the pin path +// when the list has no scrollable content (so single-screen lists +// keep the «drag-from-anywhere» pin behaviour). +// +// Size: 32 px tall — enough touch target to land on comfortably with +// a thumb (the visible grabber pill inside is much smaller, see +// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the +// equivalent placeholder) starts 32 px below the curtain's top edge. +export const HANDLE_HEIGHT_PX = 32; diff --git a/src/app/components/stream-header/useCurtainGesture.ts b/src/app/components/stream-header/useCurtainGesture.ts index cf154a7b..abf0aac0 100644 --- a/src/app/components/stream-header/useCurtainGesture.ts +++ b/src/app/components/stream-header/useCurtainGesture.ts @@ -6,7 +6,6 @@ import { DIRECTION_DEAD_ZONE_PX, PEEK_TRAVEL_PX, PIN_COMMIT_THRESHOLD, - PIN_RUBBER_BAND, PIN_TRAVEL_PX, RUBBER_BAND, } from './geometry'; @@ -58,11 +57,16 @@ type Args = { // 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 path: drag DOWN while `pinned = true` tracks 1:1 clamped at -// +PIN_TRAVEL_PX. On release past the same threshold flips -// `pinned = false`. The user-confirmed UX choice is single-stop — -// unpin lands at `closed` only; peek requires a separate gesture. +// 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`. @@ -160,26 +164,51 @@ export function useCurtainGesture({ direction = delta > 0 ? 'down' : 'up'; // Direction guards: - // - pinned ⇒ only DOWN (unpin); UP would try to push past - // y = -safe-top into the system-tray zone, which we never - // want. + // - 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 (new — used to bail). - // - closed + DOWN ⇒ peek path (existing). - if (currentPinned && direction === 'up') { + // - closed + UP ⇒ pin path (only when list has no scroll). + // - closed + DOWN ⇒ peek path. + if (currentPinned) { startX = null; startY = null; direction = null; return; } - if (!currentPinned && currentSnap === 'peek' && direction === 'down') { + // 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 (!currentPinned && isFormSnap(currentSnap) && direction === 'down') { + 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; @@ -190,27 +219,24 @@ export function useCurtainGesture({ engaged = true; e.preventDefault(); - if (currentPinned) { - // Unpin: finger moves DOWN (delta > 0). Rubber-banded by - // PIN_RUBBER_BAND so the curtain feels heavy — same factor as - // the pin path below for symmetry. Clamped at +PIN_TRAVEL_PX - // so the curtain doesn't visually descend past its `closed` - // resting top during the drag. - lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * PIN_RUBBER_BAND)); - } else if (isFormSnap(currentSnap)) { + // 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: finger moves UP (delta < 0). Rubber-banded by - // PIN_RUBBER_BAND — the curtain moves at ≈45% of finger speed - // so the user has to commit a deliberate sustained pull to - // close the gap. Clamped at -PIN_TRAVEL_PX so it never enters - // the system-tray safe-top zone. See geometry's «pinned visual - // contract» and `PIN_RUBBER_BAND` rationale for the full - // invariant. - lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * PIN_RUBBER_BAND)); + // 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 @@ -233,16 +259,10 @@ export function useCurtainGesture({ let next: CurtainSnap = currentSnap; let nextPinned = currentPinned; - if (currentPinned) { - // Unpin commit: drag DOWN past ≥ PIN_COMMIT_THRESHOLD of the - // full travel flips pinned → false. The user-confirmed UX is - // single-stop — unpin lands at `closed`; peek requires a - // separate gesture. - const progress = lastDelta / PIN_TRAVEL_PX; - if (progress >= PIN_COMMIT_THRESHOLD) { - nextPinned = false; - } - } else if (isFormSnap(currentSnap)) { + // 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'; } diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts new file mode 100644 index 00000000..9f6a5907 --- /dev/null +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -0,0 +1,248 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; +import { isNativePlatform } from '../../utils/capacitor'; +import { DIRECTION_DEAD_ZONE_PX, PIN_COMMIT_THRESHOLD, PIN_TRAVEL_PX } from './geometry'; +import { CurtainSnap } from './useCurtainState'; + +type Args = { + // Drag-handle element at the top of the curtain. Touch events bind + // here; the chat list's scroll viewport is untouched so native + // vertical scroll never races the pin gesture. Mounted as the first + // flex child of the curtain in StreamHeader.tsx. + handleRef: MutableRefObject; + // Current snap stop. Mirrored into a ref so the listener (bound + // once per `disabled` flip) reads fresh values without re-attaching. + // Only `closed` (→ pin) and the pinned overlay (→ unpin) engage. + snap: CurtainSnap; + // Per-pane pinned overlay. When true the handle's drag-down path + // commits unpin; when false (and `snap === 'closed'`) drag-up + // commits pin. + pinned: boolean; + // Setter for the pinned overlay; called on release once the user's + // drag 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. + setLiveDrag: (px: number, dragging: boolean) => void; + // Suppress gesture binding entirely. Used to gate pinning when a + // bottom sheet is open or when this pane is inactive inside the + // swipe pager. Mirrors `useCurtainGesture.disabled`. + disabled?: boolean; + // Optional sink for handle-visual state — used by StreamHeader to + // drive the grabber pill's «idle / dragging / threshold reached» + // appearance via `data-dragging` and `data-at-commit` attributes + // on the handle div. Called only when the state actually changes, + // so the consumer doesn't pay a re-render on every touchmove. + setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void; +}; + +// Touch-gesture driver for the curtain's pin / unpin path on the +// dedicated handle strip. Native-only: listeners aren't attached on +// web / desktop. +// +// Why a separate hook? The list-bound `useCurtainGesture` had its pin +// path on the chat list's scroll viewport, where drag-up at +// `scrollTop === 0` collided with the user's natural «scroll list +// down» gesture (preventDefault on touchmove blocked native scroll for +// any pin attempt). Moving pin / unpin onto a small handle above the +// list resolves the conflict — the list-bound hook now lets native +// scroll handle vertical drags on the list, and the handle owns the +// pin transitions. +// +// Pin path: from `closed` (and not pinned), drag UP tracks the finger +// 1:1 (no rubber-band) clamped at -PIN_TRAVEL_PX. On release past +// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` (≈ 61 px finger pull) flips +// `pinned = true`. +// +// Unpin path: from pinned, drag DOWN tracks 1:1 clamped at +// +PIN_TRAVEL_PX. Same commit threshold. +// +// Other snap states (peek, form-*) are no-ops here — the existing +// list-bound gesture continues to own those transitions. +export function useCurtainHandleGesture({ + handleRef, + snap, + pinned, + setPinned, + setLiveDrag, + disabled, + setHandleState, +}: Args): void { + const snapRef = useRef(snap); + snapRef.current = snap; + const pinnedRef = useRef(pinned); + pinnedRef.current = pinned; + const setPinnedRef = useRef(setPinned); + setPinnedRef.current = setPinned; + const setHandleStateRef = useRef(setHandleState); + setHandleStateRef.current = setHandleState; + + useEffect(() => { + if (!isNativePlatform()) return undefined; + if (disabled) return undefined; + const handle = handleRef.current; + if (!handle) return undefined; + + let startX: number | null = null; + let startY: number | null = null; + let direction: 'up' | 'down' | null = null; + let engaged = false; + let lastDelta = 0; + // Last visual state emitted to the consumer. We dedupe here so the + // setter (likely a React useState) only re-renders when something + // actually changed, not on every 60fps touchmove. + let emittedDragging = false; + let emittedAtCommit = false; + const emitHandle = (dragging: boolean, atCommit: boolean) => { + if (dragging === emittedDragging && atCommit === emittedAtCommit) return; + emittedDragging = dragging; + emittedAtCommit = atCommit; + setHandleStateRef.current?.({ dragging, atCommit }); + }; + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + // Engage only when the gesture has somewhere to go: + // - not pinned + closed ⇒ pin path armed + // - pinned (any snap) ⇒ unpin path armed + // Peek and form snaps are owned by the list-bound gesture. + if (!pinnedRef.current && snapRef.current !== 'closed') return; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + direction = null; + engaged = false; + lastDelta = 0; + }; + + 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; + emitHandle(false, false); + return; + } + if (startY === null) return; + + const delta = e.touches[0].clientY - startY; + const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; + 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 |dx| strictly exceeds |dy|, the user is + // swiping the mobile tab pager, not pulling the curtain. Drop + // tracking so the pager owns the gesture. + if (Math.abs(deltaX) > Math.abs(delta)) { + startX = null; + startY = null; + direction = null; + return; + } + direction = delta > 0 ? 'down' : 'up'; + // Direction guards: + // - !pinned ⇒ only UP (pin); DOWN has nowhere lower to go + // from closed on this surface (peek is owned by the list). + // - pinned ⇒ only DOWN (unpin); UP would push the curtain + // into the system-tray safe-top zone. + if (!currentPinned && direction === 'down') { + startX = null; + startY = null; + direction = null; + return; + } + if (currentPinned && direction === 'up') { + startX = null; + startY = null; + direction = null; + return; + } + } + + engaged = true; + e.preventDefault(); + + if (currentPinned) { + // Unpin: 1:1 down, clamped so the curtain doesn't descend + // past its `closed` resting top during the drag. + lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta)); + } else { + // Pin: 1:1 up, clamped so the curtain doesn't enter the + // system-tray safe-top zone. + lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta)); + } + setLiveDrag(lastDelta, true); + // Emit handle visual state. Progress is the fraction of + // PIN_TRAVEL_PX the curtain has covered (positive for unpin, + // sign-flipped for pin so both directions read «how close to + // committing»). atCommit flips once the clamp threshold is + // reached so the grabber pill stretches + brightens to signal + // «release here to confirm». + const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX; + emitHandle(true, progress >= PIN_COMMIT_THRESHOLD); + }; + + const onTouchEnd = () => { + if (!engaged) { + startX = null; + startY = null; + direction = null; + return; + } + const currentPinned = pinnedRef.current; + // Progress is the fraction of PIN_TRAVEL_PX the curtain visually + // covered. For pin (drag-up) `lastDelta` is negative; for unpin + // (drag-down) it's positive — both flips use the same magnitude + // threshold. + const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX; + if (progress >= PIN_COMMIT_THRESHOLD) { + // 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(!currentPinned); + } 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; + emitHandle(false, false); + }; + + const onTouchCancel = () => { + if (engaged) setLiveDrag(0, false); + startX = null; + startY = null; + direction = null; + engaged = false; + lastDelta = 0; + emitHandle(false, false); + }; + + handle.addEventListener('touchstart', onTouchStart, { passive: true }); + handle.addEventListener('touchmove', onTouchMove, { passive: false }); + handle.addEventListener('touchend', onTouchEnd, { passive: true }); + handle.addEventListener('touchcancel', onTouchCancel, { passive: true }); + return () => { + handle.removeEventListener('touchstart', onTouchStart); + handle.removeEventListener('touchmove', onTouchMove); + handle.removeEventListener('touchend', onTouchEnd); + handle.removeEventListener('touchcancel', onTouchCancel); + }; + // setLiveDrag is a stable useCallback; handleRef 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 + }, [handleRef, setLiveDrag, disabled]); +}