@astryxdesign/core 0.7.6 → 0.7.7-canary.0eb44a2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AppShell/AppShell.d.ts.map +1 -1
- package/dist/AppShell/AppShell.js +23 -3
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +16 -9
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +2 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +3 -2
- package/dist/Chat/ChatComposerInput.d.ts +9 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +2 -0
- package/dist/Chat/ChatMessage.js +2 -2
- package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
- package/dist/Chat/chatMessage.vars.stylex.js +8 -8
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +8 -1
- package/dist/DateInput/MonthScroller.d.ts.map +1 -1
- package/dist/DateInput/MonthScroller.js +72 -2
- package/dist/DateInput/Wheel.d.ts +17 -1
- package/dist/DateInput/Wheel.d.ts.map +1 -1
- package/dist/DateInput/Wheel.js +179 -19
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +63 -49
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +37 -1
- package/dist/Drawer/Drawer.d.ts.map +1 -1
- package/dist/Drawer/Drawer.js +6 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
- package/dist/Drawer/useDrawerDialogPresence.js +4 -0
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +20 -4
- package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
- package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
- package/dist/DropdownMenu/useMenuOverflow.js +31 -8
- package/dist/Field/PanelSearchInput.d.ts.map +1 -1
- package/dist/Field/PanelSearchInput.js +8 -1
- package/dist/Field/textEntryTraits.d.ts +6 -6
- package/dist/Field/textEntryTraits.js +6 -6
- package/dist/Layer/layerClickBoundary.d.ts +7 -1
- package/dist/Layer/layerClickBoundary.d.ts.map +1 -1
- package/dist/Layer/layerClickBoundary.js +2 -2
- package/dist/Layer/usePressPin.d.ts +95 -0
- package/dist/Layer/usePressPin.d.ts.map +1 -0
- package/dist/Layer/usePressPin.js +365 -0
- package/dist/Layer/useTouchTrigger.d.ts +23 -1
- package/dist/Layer/useTouchTrigger.d.ts.map +1 -1
- package/dist/Layer/useTouchTrigger.js +45 -7
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +2 -1
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +1 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +56 -7
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +38 -30
- package/dist/ProgressBar/ProgressBar.d.ts +1 -1
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +52 -14
- package/dist/ProgressBar/ProgressBarTouch.d.ts +29 -0
- package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBarTouch.js +91 -0
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +2 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +25 -13
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +404 -131
- package/dist/Slider/sliderTouchController.d.ts +189 -0
- package/dist/Slider/sliderTouchController.d.ts.map +1 -0
- package/dist/Slider/sliderTouchController.js +1017 -0
- package/dist/Slider/useSliderTouch.d.ts +22 -0
- package/dist/Slider/useSliderTouch.d.ts.map +1 -0
- package/dist/Slider/useSliderTouch.js +56 -0
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +200 -46
- package/dist/Switch/switchFill.d.ts +31 -0
- package/dist/Switch/switchFill.d.ts.map +1 -0
- package/dist/Switch/switchFill.js +88 -0
- package/dist/Switch/switchTouch.d.ts +81 -0
- package/dist/Switch/switchTouch.d.ts.map +1 -0
- package/dist/Switch/switchTouch.js +133 -0
- package/dist/Switch/useSwitchTouch.d.ts +285 -0
- package/dist/Switch/useSwitchTouch.d.ts.map +1 -0
- package/dist/Switch/useSwitchTouch.js +998 -0
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +4 -3
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +12 -5
- package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts +3 -0
- package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -0
- package/dist/Tokenizer/TokenizerItemRemovableContext.js +17 -0
- package/dist/Tooltip/Tooltip.d.ts +30 -6
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +5 -0
- package/dist/Tooltip/index.d.ts +1 -1
- package/dist/Tooltip/index.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.d.ts +31 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +59 -18
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +19 -9
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +26 -15
- package/dist/astryx.css +91 -8
- package/dist/gestures/GestureArena.d.ts +21 -2
- package/dist/gestures/GestureArena.d.ts.map +1 -1
- package/dist/gestures/GestureArena.js +23 -3
- package/dist/gestures/GestureHost.d.ts +123 -3
- package/dist/gestures/GestureHost.d.ts.map +1 -1
- package/dist/gestures/GestureHost.js +797 -15
- package/dist/gestures/GestureRecognizer.d.ts +15 -1
- package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/GestureRecognizer.js +12 -0
- package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
- package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
- package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/PinchGestureRecognizer.js +10 -0
- package/dist/gestures/PressTracker.d.ts +6 -0
- package/dist/gestures/PressTracker.d.ts.map +1 -1
- package/dist/gestures/PressTracker.js +3 -0
- package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
- package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
- package/dist/gestures/nativeScroll.d.ts +16 -2
- package/dist/gestures/nativeScroll.d.ts.map +1 -1
- package/dist/gestures/nativeScroll.js +39 -8
- package/dist/gestures/recognizerInternals.d.ts +4 -2
- package/dist/gestures/recognizerInternals.d.ts.map +1 -1
- package/dist/gestures/types.d.ts +17 -0
- package/dist/gestures/types.d.ts.map +1 -1
- package/dist/gestures/types.js +7 -0
- package/dist/gestures/usePressTracker.d.ts.map +1 -1
- package/dist/gestures/usePressTracker.js +1 -0
- package/dist/gestures/wheelSegmenter.d.ts +56 -0
- package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
- package/dist/gestures/wheelSegmenter.js +122 -0
- package/dist/hooks/useAttachment.d.ts +16 -8
- package/dist/hooks/useAttachment.d.ts.map +1 -1
- package/dist/hooks/useAttachment.js +35 -15
- package/dist/hooks/useFocusTrap.d.ts +15 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +13 -2
- package/dist/hooks/usePager.d.ts +261 -0
- package/dist/hooks/usePager.d.ts.map +1 -0
- package/dist/hooks/usePager.js +1169 -0
- package/dist/motion/animator.d.ts.map +1 -1
- package/dist/motion/animator.js +140 -19
- package/dist/motion/paging.d.ts +69 -0
- package/dist/motion/paging.d.ts.map +1 -0
- package/dist/motion/paging.js +148 -0
- package/dist/motion/uikit/index.d.ts +8 -4
- package/dist/motion/uikit/index.d.ts.map +1 -1
- package/dist/motion/uikit/index.js +8 -4
- package/dist/motion/uikit/paging.d.ts +171 -0
- package/dist/motion/uikit/paging.d.ts.map +1 -0
- package/dist/motion/uikit/paging.js +192 -0
- package/dist/motion/uikit/progressBar.d.ts +79 -0
- package/dist/motion/uikit/progressBar.d.ts.map +1 -0
- package/dist/motion/uikit/progressBar.js +102 -0
- package/dist/motion/uikit/scroll.d.ts +0 -19
- package/dist/motion/uikit/scroll.d.ts.map +1 -1
- package/dist/motion/uikit/scroll.js +2 -21
- package/dist/motion/uikit/slider.d.ts +97 -0
- package/dist/motion/uikit/slider.d.ts.map +1 -0
- package/dist/motion/uikit/slider.js +111 -0
- package/dist/motion/uikit/springs.d.ts +59 -30
- package/dist/motion/uikit/springs.d.ts.map +1 -1
- package/dist/motion/uikit/springs.js +64 -34
- package/dist/motion/uikit/switch.d.ts +166 -0
- package/dist/motion/uikit/switch.d.ts.map +1 -0
- package/dist/motion/uikit/switch.js +186 -0
- package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts +18 -0
- package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/pressHoldsDrag.stylex.js +28 -0
- package/dist/utils/pressFeedback.d.ts.map +1 -1
- package/dist/utils/pressFeedback.js +5 -0
- package/dist/utils/selectionLock.d.ts +43 -0
- package/dist/utils/selectionLock.d.ts.map +1 -1
- package/dist/utils/selectionLock.js +394 -6
- package/package.json +5 -5
- package/src/AppShell/AppShell.doc.mjs +10 -0
- package/src/AppShell/AppShell.tsx +48 -2
- package/src/Avatar/Avatar.tsx +3 -1
- package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
- package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
- package/src/Button/Button.tsx +3 -2
- package/src/Chat/Chat.doc.mjs +1 -0
- package/src/Chat/ChatComposerInput.doc.mjs +11 -0
- package/src/Chat/ChatComposerInput.tsx +11 -0
- package/src/Chat/ChatMessage.tsx +2 -2
- package/src/Chat/chatMessage.vars.stylex.ts +8 -8
- package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
- package/src/DateInput/MonthScroller.tsx +63 -5
- package/src/DateInput/Wheel.tsx +174 -14
- package/src/DateRangeInput/DateRangeInput.doc.mjs +3 -2
- package/src/DateRangeInput/DateRangeInput.tsx +117 -71
- package/src/Dialog/Dialog.tsx +37 -1
- package/src/Drawer/Drawer.tsx +6 -0
- package/src/Drawer/useDrawerDialogPresence.ts +4 -0
- package/src/DropdownMenu/DropdownMenu.tsx +28 -6
- package/src/DropdownMenu/useMenuOverflow.ts +44 -8
- package/src/Field/PanelSearchInput.tsx +6 -0
- package/src/Field/textEntryTraits.ts +6 -6
- package/src/Layer/layerClickBoundary.ts +2 -2
- package/src/Layer/usePressPin.ts +516 -0
- package/src/Layer/useTouchTrigger.ts +56 -6
- package/src/Lightbox/Lightbox.tsx +2 -1
- package/src/Link/Link.tsx +3 -0
- package/src/Popover/usePopover.tsx +71 -9
- package/src/PowerSearch/PowerSearch.tsx +38 -31
- package/src/ProgressBar/ProgressBar.doc.mjs +7 -5
- package/src/ProgressBar/ProgressBar.tsx +147 -16
- package/src/ProgressBar/ProgressBarTouch.ts +109 -0
- package/src/SegmentedControl/SegmentedControlItem.tsx +2 -1
- package/src/Selector/Selector.tsx +29 -2
- package/src/Slider/Slider.doc.mjs +11 -7
- package/src/Slider/Slider.tsx +539 -132
- package/src/Slider/sliderTouchController.ts +1209 -0
- package/src/Slider/useSliderTouch.ts +78 -0
- package/src/Switch/Switch.doc.mjs +3 -3
- package/src/Switch/Switch.tsx +412 -86
- package/src/Switch/switchFill.ts +106 -0
- package/src/Switch/switchTouch.ts +194 -0
- package/src/Switch/useSwitchTouch.ts +1121 -0
- package/src/TabList/Tab.tsx +5 -3
- package/src/Tokenizer/Tokenizer.tsx +15 -5
- package/src/Tokenizer/TokenizerItemRemovableContext.ts +22 -0
- package/src/Tooltip/Tooltip.doc.mjs +21 -6
- package/src/Tooltip/Tooltip.tsx +42 -5
- package/src/Tooltip/index.ts +1 -0
- package/src/Tooltip/useTooltip.doc.mjs +4 -2
- package/src/Tooltip/useTooltip.tsx +84 -21
- package/src/Typeahead/BaseTypeahead.tsx +16 -8
- package/src/Typeahead/Typeahead.tsx +29 -15
- package/src/gestures/GestureArena.ts +45 -4
- package/src/gestures/GestureHost.ts +958 -15
- package/src/gestures/GestureRecognizer.ts +28 -0
- package/src/gestures/PanGestureRecognizer.ts +8 -0
- package/src/gestures/PinchGestureRecognizer.ts +16 -1
- package/src/gestures/PressTracker.ts +10 -0
- package/src/gestures/README.md +82 -3
- package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
- package/src/gestures/nativeScroll.ts +49 -9
- package/src/gestures/recognizerInternals.ts +9 -2
- package/src/gestures/types.ts +18 -0
- package/src/gestures/usePressTracker.ts +2 -0
- package/src/gestures/wheelSegmenter.ts +143 -0
- package/src/hooks/useAttachment.ts +56 -14
- package/src/hooks/useFocusTrap.ts +26 -2
- package/src/hooks/usePager.ts +1446 -0
- package/src/motion/MEASUREMENTS.md +36 -27
- package/src/motion/README.md +7 -3
- package/src/motion/animator.ts +193 -25
- package/src/motion/paging.ts +161 -0
- package/src/motion/uikit/index.ts +7 -4
- package/src/motion/uikit/paging.ts +193 -0
- package/src/motion/uikit/progressBar.ts +102 -0
- package/src/motion/uikit/scroll.ts +2 -18
- package/src/motion/uikit/slider.ts +105 -0
- package/src/motion/uikit/springs.ts +53 -32
- package/src/motion/uikit/switch.ts +174 -0
- package/src/privateAttributes/pressHoldsDrag.stylex.ts +28 -0
- package/src/utils/pressFeedback.ts +5 -0
- package/src/utils/selectionLock.ts +501 -6
- package/dist/motion/selectPage.d.ts +0 -26
- package/dist/motion/selectPage.d.ts.map +0 -1
- package/dist/motion/selectPage.js +0 -55
- package/src/motion/selectPage.ts +0 -55
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file usePager.ts
|
|
7
|
+
* @input A viewport and its track, a page count and gap (or the track's
|
|
8
|
+
* children), the gesture system and the animator
|
|
9
|
+
* @output Exports `createPager` (the framework-free pager), `usePager` (its
|
|
10
|
+
* React face) and their types
|
|
11
|
+
* @position Shared hook for core components that page like a UIKit paging
|
|
12
|
+
* scroll view, such as Carousel's paging presentation
|
|
13
|
+
*
|
|
14
|
+
* SYNC: When modified, update:
|
|
15
|
+
* - /packages/core/src/hooks/usePager.test.ts
|
|
16
|
+
* - /packages/core/src/hooks/__tests__/usePager.interaction.browser.spec.ts
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {useEffect, useRef, useState, type RefObject} from 'react';
|
|
20
|
+
import {getGestureHost, type GestureHost} from '../gestures/GestureHost';
|
|
21
|
+
import type {GestureRecognizer} from '../gestures/GestureRecognizer';
|
|
22
|
+
import {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer';
|
|
23
|
+
import {useAttachmentPair} from './useAttachment';
|
|
24
|
+
import {lockSelection, locksSelectionFor} from '../gestures/selectionLock';
|
|
25
|
+
import {
|
|
26
|
+
PanGestureRecognizer,
|
|
27
|
+
type PanGestureRecognizerOptions,
|
|
28
|
+
} from '../gestures/PanGestureRecognizer';
|
|
29
|
+
import type {
|
|
30
|
+
GestureEventContext,
|
|
31
|
+
GestureTouch,
|
|
32
|
+
Point,
|
|
33
|
+
PointerKind,
|
|
34
|
+
} from '../gestures/types';
|
|
35
|
+
import {
|
|
36
|
+
animatorFor,
|
|
37
|
+
type Animator,
|
|
38
|
+
type MotionCurve,
|
|
39
|
+
type MotionHandle,
|
|
40
|
+
} from '../motion/animator';
|
|
41
|
+
import {
|
|
42
|
+
pagingSettleVelocity,
|
|
43
|
+
solveAnimatedPageScroll,
|
|
44
|
+
solvePaging,
|
|
45
|
+
} from '../motion/paging';
|
|
46
|
+
import {resolveMotion} from '../motion/resolveMotion';
|
|
47
|
+
import {resolveSnapPoint} from '../motion/resolveSnapPoint';
|
|
48
|
+
import {rubberBandClamp, rubberBandUnclamp} from '../motion/rubberBand';
|
|
49
|
+
import {paging, registerPagingTable} from '../motion/uikit/paging';
|
|
50
|
+
import {observeResize} from '../utils/sharedResizeObserver';
|
|
51
|
+
import {isRtlElement} from './isRtlElement';
|
|
52
|
+
import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
|
|
53
|
+
|
|
54
|
+
/** A release the pager settles: where the content is, how fast, and the page it left. */
|
|
55
|
+
export interface PageRelease {
|
|
56
|
+
/** Content offset at the lift, px, or pages for children pages (may be past either end). */
|
|
57
|
+
position: number;
|
|
58
|
+
/** Content velocity at the release, px/s (the shared release velocity). */
|
|
59
|
+
velocity: number;
|
|
60
|
+
/** Distance between consecutive pages: page width plus the gap, px (1 for children pages). */
|
|
61
|
+
stride: number;
|
|
62
|
+
/** Number of pages (at least 1). */
|
|
63
|
+
count: number;
|
|
64
|
+
/** The page the drag started on (its rest position when the finger landed). */
|
|
65
|
+
startIndex: number;
|
|
66
|
+
/** Wraps around: the target may leave [0, count - 1] (the owner wraps it). */
|
|
67
|
+
loop?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A pan or a screen-edge pan, told apart by its kind, never by its class.
|
|
72
|
+
*/
|
|
73
|
+
function isPanFamily(
|
|
74
|
+
recognizer: GestureRecognizer,
|
|
75
|
+
): recognizer is PanGestureRecognizer {
|
|
76
|
+
return recognizer.kind === 'pan' || recognizer.kind === 'screenEdgePan';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The page a release settles on. It applies UIKit's measured paging rule
|
|
81
|
+
* (`release` in uikit/paging.ts) through the release policy of
|
|
82
|
+
* resolveSnapPoint.ts, with the pager's own pages and thresholds.
|
|
83
|
+
*
|
|
84
|
+
* - A release at `advanceSpeed` or faster is a flick: it goes to the next page
|
|
85
|
+
* in the direction of the velocity, from any content position.
|
|
86
|
+
* - A slower release goes to the page nearest the content. UIKit's measured
|
|
87
|
+
* `commitFraction` is half a page, the policy's nearest snap point.
|
|
88
|
+
* - A release whose velocity points back toward the start page settles there,
|
|
89
|
+
* at any travel, through the policy's `start`.
|
|
90
|
+
* - The page is never more than `maxPagesFromStart` from the start page
|
|
91
|
+
* (`maxSteps`). It is clamped to the pages unless the pager loops.
|
|
92
|
+
* - The policy sees only the pages within that reach of the start page. So a
|
|
93
|
+
* looping target may leave the range, and the owner wraps it.
|
|
94
|
+
*
|
|
95
|
+
* With children pages, positions are in pages, but the velocity stays px/s.
|
|
96
|
+
* The policy reads the velocity only against `advanceSpeed` and for its
|
|
97
|
+
* direction.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```
|
|
101
|
+
* // Released at rest 60 % of the way from page 1 to page 2: page 2.
|
|
102
|
+
* pageForRelease({position: 1.6 * 402, velocity: 0, stride: 402, count: 6, startIndex: 1});
|
|
103
|
+
* // Flicked forward at 600 px/s from 6 % past page 1: page 2.
|
|
104
|
+
* pageForRelease({position: 1.06 * 402, velocity: 600, stride: 402, count: 6, startIndex: 1});
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
export function pageForRelease({
|
|
109
|
+
position,
|
|
110
|
+
velocity,
|
|
111
|
+
stride,
|
|
112
|
+
count,
|
|
113
|
+
startIndex,
|
|
114
|
+
loop = false,
|
|
115
|
+
}: PageRelease): number {
|
|
116
|
+
if (!(stride > 0) || count < 1) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const reach = paging.release.maxPagesFromStart;
|
|
120
|
+
// A stale start page past either end (the count shrank, an overscrolled
|
|
121
|
+
// track) counts from the nearest real page.
|
|
122
|
+
const from = loop ? startIndex : Math.min(Math.max(startIndex, 0), count - 1);
|
|
123
|
+
const first = loop ? from - reach : Math.max(0, from - reach);
|
|
124
|
+
const last = loop ? from + reach : Math.min(count - 1, from + reach);
|
|
125
|
+
const points = Array.from(
|
|
126
|
+
{length: last - first + 1},
|
|
127
|
+
(_, i) => (first + i) * stride,
|
|
128
|
+
);
|
|
129
|
+
const {index} = resolveSnapPoint({
|
|
130
|
+
points,
|
|
131
|
+
position,
|
|
132
|
+
velocity,
|
|
133
|
+
flickThreshold: paging.release.advanceSpeed,
|
|
134
|
+
// A hair of tolerance so a release exactly on a page reads as on it.
|
|
135
|
+
tolerance: 1e-6 * stride,
|
|
136
|
+
start: from * stride,
|
|
137
|
+
fromIndex: from - first,
|
|
138
|
+
maxSteps: reach,
|
|
139
|
+
});
|
|
140
|
+
return first + index;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Why the target page changed. */
|
|
144
|
+
export type PagerChangeReason = 'drag' | 'goTo' | 'controlled';
|
|
145
|
+
|
|
146
|
+
export interface PagerOptions {
|
|
147
|
+
/** Number of pages (at least 1). */
|
|
148
|
+
count: number;
|
|
149
|
+
/** Space between pages, px; it shows only while a page moves. @default 0 */
|
|
150
|
+
gap?: number;
|
|
151
|
+
/** Pages wrap around; indexes become unbounded. @default false */
|
|
152
|
+
loop?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* What a page is. With `'viewport'`, every page is the viewport's width, at
|
|
155
|
+
* index × (width + `gap`). With `'children'`, each child of the track is a
|
|
156
|
+
* page at its own start edge, as `scroll-snap-align: start` places it.
|
|
157
|
+
*
|
|
158
|
+
* With children pages, the viewport's padding stays an inset. The travel
|
|
159
|
+
* ends where the track's end meets the end of the viewport's content box. A
|
|
160
|
+
* child whose start lies past that point rests there, so the last children
|
|
161
|
+
* can share one page. The count is then the number of distinct pages
|
|
162
|
+
* (`pageCount`). `count` and `gap` serve only before the first measure, and
|
|
163
|
+
* children pages do not loop. The pager measures the children on attach, on
|
|
164
|
+
* a resize, on an options change and when a drag begins. It never measures
|
|
165
|
+
* while a finger moves.
|
|
166
|
+
* @default 'viewport'
|
|
167
|
+
*/
|
|
168
|
+
pages?: 'viewport' | 'children';
|
|
169
|
+
/** Starting page. @default 0 */
|
|
170
|
+
defaultIndex?: number;
|
|
171
|
+
/**
|
|
172
|
+
* The target page changed (index wrapped into [0, count)). A release
|
|
173
|
+
* reports once its settle is moving; `index` has the page from the lift.
|
|
174
|
+
* A controlled move (the `index` prop, or a `count` that clamps the page)
|
|
175
|
+
* is the owner's own and is not reported.
|
|
176
|
+
*/
|
|
177
|
+
onIndexChange?: (index: number, reason: PagerChangeReason) => void;
|
|
178
|
+
/**
|
|
179
|
+
* The track came to rest on `index` after moving on its own (a settle, a
|
|
180
|
+
* `goTo`), having last rested on `previous` (equal when it returned).
|
|
181
|
+
*/
|
|
182
|
+
onSettle?: (index: number, previous: number) => void;
|
|
183
|
+
/**
|
|
184
|
+
* With `pages: 'children'`: a measure found different pages (how many, or
|
|
185
|
+
* where they rest) after the viewport or the track resized or the options
|
|
186
|
+
* changed. Not called for the measure an attach makes: read `pageCount`
|
|
187
|
+
* after attaching.
|
|
188
|
+
*/
|
|
189
|
+
onPagesChange?: (pageCount: number) => void;
|
|
190
|
+
/**
|
|
191
|
+
* Called when the pan would begin, with the drag's first physical direction
|
|
192
|
+
* on screen, the same in RTL. Return false to leave the touch to someone
|
|
193
|
+
* else, such as a zoomed photo that can still pan that way.
|
|
194
|
+
*/
|
|
195
|
+
shouldBeginPan?: (direction: 'left' | 'right') => boolean;
|
|
196
|
+
/** Constructor options for the pan (e.g. `allowedPointerTypes`). */
|
|
197
|
+
panOptions?: Partial<PanGestureRecognizerOptions>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface GoToOptions {
|
|
201
|
+
/** Animate with `setContentOffset(_:animated:)`'s curve. @default true */
|
|
202
|
+
animated?: boolean;
|
|
203
|
+
/** @default 'goTo' */
|
|
204
|
+
reason?: PagerChangeReason;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface Pager {
|
|
208
|
+
/** The page pan, for failure requirements and simultaneity. */
|
|
209
|
+
readonly pan: PanGestureRecognizer;
|
|
210
|
+
/** The target page, wrapped into [0, count). */
|
|
211
|
+
readonly index: number;
|
|
212
|
+
/** The target page, unbounded when looping. */
|
|
213
|
+
readonly virtualIndex: number;
|
|
214
|
+
/**
|
|
215
|
+
* How many pages there are: `count`, or with `pages: 'children'` the
|
|
216
|
+
* distinct pages last measured.
|
|
217
|
+
*/
|
|
218
|
+
readonly pageCount: number;
|
|
219
|
+
/** Whether the track is moving on its own (a settle or a `goTo`). */
|
|
220
|
+
readonly isSettling: boolean;
|
|
221
|
+
/** Whether a finger is dragging the track. */
|
|
222
|
+
readonly isDragging: boolean;
|
|
223
|
+
/** The content offset on screen right now, from the curve (px). */
|
|
224
|
+
presentation(): number;
|
|
225
|
+
/** Where page `index` rests (px). */
|
|
226
|
+
pageOffset(index: number): number;
|
|
227
|
+
/** The page whose rest is nearest content offset `position` (px). */
|
|
228
|
+
nearestPage(position: number): number;
|
|
229
|
+
/** Move to a page (wrapped to the nearest copy when looping). */
|
|
230
|
+
goTo(index: number, options?: GoToOptions): void;
|
|
231
|
+
/** Update options; a smaller count clamps the target page. */
|
|
232
|
+
setOptions(options: Partial<PagerOptions>): void;
|
|
233
|
+
/**
|
|
234
|
+
* Turns the page pan on or off (the hook's `isPanEnabled`). While it is off,
|
|
235
|
+
* a touch does not stop a settle or a `goTo`. A drag that it ends settles as
|
|
236
|
+
* a cancelled drag does. The callbacks of that settle wait for the next
|
|
237
|
+
* frame, so no callback runs inside the caller's update.
|
|
238
|
+
*/
|
|
239
|
+
setPanEnabled(enabled: boolean): void;
|
|
240
|
+
/** Attach to the elements; returns a detach function. */
|
|
241
|
+
attach(viewport: HTMLElement, track: HTMLElement): () => void;
|
|
242
|
+
/** Detach and stop everything (attaching again restarts it). */
|
|
243
|
+
dispose(): void;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The copy of page `index` nearest virtual page `from`, in a loop of `count`
|
|
248
|
+
* pages. Two copies can be equally near: with two pages, or half an even
|
|
249
|
+
* count away. Then it is `index` itself when that is one of them, so a step
|
|
250
|
+
* to `from + 1` always goes forward.
|
|
251
|
+
*/
|
|
252
|
+
export function nearestLoopCopy(
|
|
253
|
+
index: number,
|
|
254
|
+
from: number,
|
|
255
|
+
count: number,
|
|
256
|
+
): number {
|
|
257
|
+
if (Math.abs(index - from) * 2 <= count) {
|
|
258
|
+
return index;
|
|
259
|
+
}
|
|
260
|
+
return Math.round(
|
|
261
|
+
from +
|
|
262
|
+
(((mod(index, count) - mod(from, count) + count + count / 2) % count) -
|
|
263
|
+
count / 2),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Whether two measured page lists are the same to within half a pixel. */
|
|
268
|
+
function sameOffsets(
|
|
269
|
+
a: ReadonlyArray<number>,
|
|
270
|
+
b: ReadonlyArray<number> | null,
|
|
271
|
+
): boolean {
|
|
272
|
+
return (
|
|
273
|
+
b != null &&
|
|
274
|
+
a.length === b.length &&
|
|
275
|
+
a.every((offset, i) => Math.abs(offset - b[i]) < 0.5)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function mod(value: number, count: number): number {
|
|
280
|
+
return ((value % count) + count) % count;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A position curve seen as the track's x (mirrored for LTR). */
|
|
284
|
+
function toTrackCurve(curve: MotionCurve, sign: number): MotionCurve {
|
|
285
|
+
// `+ 0` turns a mirrored zero (−0) into 0.
|
|
286
|
+
return {
|
|
287
|
+
duration: curve.duration,
|
|
288
|
+
at: t => sign * curve.at(t) + 0,
|
|
289
|
+
velocity: t => sign * curve.velocity(t) + 0,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** What a pager runs on; tests pass their own clocked host and animator. */
|
|
294
|
+
export interface PagerEnvironment {
|
|
295
|
+
/** The gesture host; the document's shared host by default. */
|
|
296
|
+
host?: GestureHost;
|
|
297
|
+
/** The track's animator; `animatorFor(track)` by default. */
|
|
298
|
+
animator?: Animator;
|
|
299
|
+
/** Timers for the catch delay; the viewport's window's by default. */
|
|
300
|
+
setTimeout?: (callback: () => void, ms: number) => unknown;
|
|
301
|
+
clearTimeout?: (handle: unknown) => void;
|
|
302
|
+
/** The next animation frame; the viewport's window's by default. */
|
|
303
|
+
requestFrame?: (callback: () => void) => void;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The pager's pan. Its release velocity is the pan's `scrollVelocity()`, the
|
|
308
|
+
* one UIScrollView hands `scrollViewWillEndDragging`. Unlike UIKit's, it reads
|
|
309
|
+
* 0 after a still hold.
|
|
310
|
+
*
|
|
311
|
+
* Inside a scroll view, it takes UIKit's rule: a drag whose dominant axis is
|
|
312
|
+
* horizontal is the pager's (`paging.nestedPanAxisRatio`, the tie included).
|
|
313
|
+
* Any other drag is the page's. Like every pan, it cancels the touch's moves
|
|
314
|
+
* once it begins, at its 10 pt hysteresis. Nothing claims a touch earlier.
|
|
315
|
+
*
|
|
316
|
+
* Inside a sheet, it keeps the sheet's rule (`axisRatio`,
|
|
317
|
+
* `paging.panAxisRatio`). The arena shows the sheet as an enclosing vertical
|
|
318
|
+
* pan that takes part in the touch. Between atan(1 ÷ that ratio) and 45°,
|
|
319
|
+
* neither moves.
|
|
320
|
+
*/
|
|
321
|
+
class PagerPanGestureRecognizer extends PanGestureRecognizer {
|
|
322
|
+
/**
|
|
323
|
+
* The kind of pointer the pan follows. Under Reduce Motion a touch's
|
|
324
|
+
* release cross-fades; a mouse's or a pen's lands at once on a fine
|
|
325
|
+
* pointer.
|
|
326
|
+
*/
|
|
327
|
+
pointerType: PointerKind | null = null;
|
|
328
|
+
|
|
329
|
+
protected override touchesBegan(
|
|
330
|
+
touches: ReadonlyArray<GestureTouch>,
|
|
331
|
+
event: GestureEventContext,
|
|
332
|
+
): void {
|
|
333
|
+
if (this.state === 'possible' && touches.length > 0) {
|
|
334
|
+
this.pointerType = touches[0].pointerType;
|
|
335
|
+
}
|
|
336
|
+
super.touchesBegan(touches, event);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private hasEnclosingVerticalPan(): boolean {
|
|
340
|
+
for (const other of this.arena?.involvedRecognizers ?? []) {
|
|
341
|
+
if (
|
|
342
|
+
other !== this &&
|
|
343
|
+
isPanFamily(other) &&
|
|
344
|
+
(other.direction === 'vertical' ||
|
|
345
|
+
other.direction === 'up' ||
|
|
346
|
+
other.direction === 'down')
|
|
347
|
+
) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
protected override classify(translation: Point): 'begin' | 'fail' | 'wait' {
|
|
355
|
+
const decided = super.classify(translation);
|
|
356
|
+
if (decided !== 'wait' || this.hasEnclosingVerticalPan()) {
|
|
357
|
+
return decided;
|
|
358
|
+
}
|
|
359
|
+
const along = Math.abs(translation.x);
|
|
360
|
+
const across = Math.abs(translation.y);
|
|
361
|
+
if (along === 0 && across === 0) {
|
|
362
|
+
return 'wait';
|
|
363
|
+
}
|
|
364
|
+
return along >= paging.nestedPanAxisRatio * across ? 'begin' : 'fail';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The settle catcher: a zero-duration long press that only stops the settle.
|
|
370
|
+
* It never claims the touch. So a vertical swipe or a pinch that starts on a
|
|
371
|
+
* settling track still reaches the page. UIKit also hands the outer scroll
|
|
372
|
+
* view a vertical drag after the paging view's content stops.
|
|
373
|
+
*/
|
|
374
|
+
class SettleCatcher extends LongPressGestureRecognizer {
|
|
375
|
+
/** The kind of pointer that caught the settle (see the pager's pan). */
|
|
376
|
+
pointerType: PointerKind | null = null;
|
|
377
|
+
|
|
378
|
+
override get claimsTouches(): boolean {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
protected override touchesBegan(
|
|
383
|
+
touches: ReadonlyArray<GestureTouch>,
|
|
384
|
+
event: GestureEventContext,
|
|
385
|
+
): void {
|
|
386
|
+
if (this.state === 'possible' && touches.length > 0) {
|
|
387
|
+
this.pointerType = touches[0].pointerType;
|
|
388
|
+
}
|
|
389
|
+
super.touchesBegan(touches, event);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A trackpad swipe that moved under this fraction of a page (in the pages'
|
|
395
|
+
* own spacing, children pages included) releases still, whatever its
|
|
396
|
+
* speed.
|
|
397
|
+
*/
|
|
398
|
+
const STILL_RELEASE_FRACTION = 0.1;
|
|
399
|
+
|
|
400
|
+
/** Whether a pan's contact is a scroll (a trackpad's), not a pointer. */
|
|
401
|
+
function isScrollContact(recognizer: PanGestureRecognizer): boolean {
|
|
402
|
+
return recognizer.trackedTouches[0]?.scrollType != null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Creates a pager: UIKit's paging scroll view, driven by the gesture system
|
|
407
|
+
* and played on the compositor. A native scroll-snap container does not run
|
|
408
|
+
* UIKit's paging integrator, and it cannot share a gesture with a pinch.
|
|
409
|
+
* Attach the pager to its viewport and track. It measures the page width on
|
|
410
|
+
* attach, on a resize and when a drag begins, never while a finger moves.
|
|
411
|
+
*
|
|
412
|
+
* - A horizontal pan moves the track 1:1, with one `translate` write per
|
|
413
|
+
* move, no layout read and no React render. Past the first or last page,
|
|
414
|
+
* the track follows UIKit's rubber band, with d = the page width.
|
|
415
|
+
* - At the lift, `pageForRelease` picks the page with UIKit's measured rule.
|
|
416
|
+
* The track then settles on UIKit's paging integrator (`solvePaging`),
|
|
417
|
+
* baked into keyframes.
|
|
418
|
+
* - A finger that lands on a settling pager catches it `paging.catchStopMs`
|
|
419
|
+
* after the touch, as in UIKit. A drag catches it at once, from the value
|
|
420
|
+
* on screen. While the pan is off, a touch does not stop the motion.
|
|
421
|
+
* - `goTo` runs the curve of `setContentOffset(_:animated:)` and cancels a
|
|
422
|
+
* drag in progress. It reads that duration and curve through
|
|
423
|
+
* `resolveMotion`.
|
|
424
|
+
* - Under Reduce Motion, the drag still follows the finger, but nothing
|
|
425
|
+
* travels after the lift. A touch's release cross-fades in 200 ms on any
|
|
426
|
+
* pointer. `goTo` and a mouse's or a pen's release cross-fade on a coarse
|
|
427
|
+
* pointer, and land at once on any other.
|
|
428
|
+
* - `onIndexChange` reports a release's page once its settle runs on the
|
|
429
|
+
* compositor, two frames after the frame it started in. It reports a
|
|
430
|
+
* `goTo` at once, and never a controlled change. A report still waiting at
|
|
431
|
+
* a detach or a controlled move is dropped. `onSettle` runs when the track
|
|
432
|
+
* rests.
|
|
433
|
+
* - A detach ends a drag on its start page, and an unmount calls nothing.
|
|
434
|
+
* When a replaced viewport or track attaches, a motion that the detach
|
|
435
|
+
* stopped ends on its page. Its report and `onSettle` go out from a frame.
|
|
436
|
+
* - A controlled `index` wins. A page that the pager reports and the owner
|
|
437
|
+
* does not take returns the pager to `index`, without a report.
|
|
438
|
+
* - The pan and the catch attach for every pointer. A mouse drags the track
|
|
439
|
+
* as a finger does. The pan follows a trackpad's scroll along its axis, but
|
|
440
|
+
* never a mouse wheel's.
|
|
441
|
+
* - A trackpad swipe moves the track at most one page from its start page.
|
|
442
|
+
* A swipe that moves under a tenth of a page releases still, at any speed.
|
|
443
|
+
* A swipe that lands during the last swipe's settle begins from that
|
|
444
|
+
* settle's page.
|
|
445
|
+
*
|
|
446
|
+
* Positions are content offsets in px, 0 at page 0 and growing toward later
|
|
447
|
+
* pages. The track is translated by −position, or by +position in a
|
|
448
|
+
* right-to-left viewport. The owner renders the pages: all of them, a window,
|
|
449
|
+
* or a wrapped copy for a loop. With `loop`, indexes are unbounded, and
|
|
450
|
+
* `index` wraps them into [0, count).
|
|
451
|
+
*
|
|
452
|
+
* Give the current page and its neighbors their own layers
|
|
453
|
+
* (`will-change: transform`). Through a busy main thread, Mobile Safari moves
|
|
454
|
+
* the track but cannot paint new content, so part of a page that slides in
|
|
455
|
+
* stays blank. A page on its own layer is painted whole before it moves.
|
|
456
|
+
*/
|
|
457
|
+
export function createPager(
|
|
458
|
+
initial: PagerOptions,
|
|
459
|
+
environment: PagerEnvironment = {},
|
|
460
|
+
): Pager {
|
|
461
|
+
registerPagingTable();
|
|
462
|
+
// Frames and timers come from the viewport's own window, known from the
|
|
463
|
+
// attach. In a Picture-in-Picture window, the opener's frames stop while
|
|
464
|
+
// its tab is hidden.
|
|
465
|
+
let view: (Window & typeof globalThis) | null = null;
|
|
466
|
+
const realm = () => view ?? globalThis;
|
|
467
|
+
const setTimer =
|
|
468
|
+
environment.setTimeout ??
|
|
469
|
+
((callback: () => void, ms: number) => realm().setTimeout(callback, ms));
|
|
470
|
+
const clearTimer =
|
|
471
|
+
environment.clearTimeout ??
|
|
472
|
+
((handle: unknown) =>
|
|
473
|
+
realm().clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
474
|
+
const requestFrame =
|
|
475
|
+
environment.requestFrame ??
|
|
476
|
+
((callback: () => void) => {
|
|
477
|
+
const frames = realm();
|
|
478
|
+
if (typeof frames.requestAnimationFrame === 'function') {
|
|
479
|
+
frames.requestAnimationFrame(() => callback());
|
|
480
|
+
} else {
|
|
481
|
+
setTimer(callback, 16);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
let options: PagerOptions = {...initial};
|
|
485
|
+
let viewport: HTMLElement | null = null;
|
|
486
|
+
let trackElement: HTMLElement | null = null;
|
|
487
|
+
let animator: Animator | null = null;
|
|
488
|
+
let pageWidth = 0;
|
|
489
|
+
let isRtl = false;
|
|
490
|
+
// With `pages: 'children'`, each page's offset (px, ascending), once
|
|
491
|
+
// measured.
|
|
492
|
+
let childOffsets: ReadonlyArray<number> | null = null;
|
|
493
|
+
|
|
494
|
+
const isChildPages = () => options.pages === 'children';
|
|
495
|
+
const isLooping = () => options.loop === true && !isChildPages();
|
|
496
|
+
const count = () =>
|
|
497
|
+
isChildPages() && childOffsets != null
|
|
498
|
+
? childOffsets.length
|
|
499
|
+
: Math.max(1, Math.floor(options.count));
|
|
500
|
+
const stride = () => pageWidth + (options.gap ?? 0);
|
|
501
|
+
const sign = () => (isRtl ? 1 : -1);
|
|
502
|
+
const clampIndex = (index: number) =>
|
|
503
|
+
isLooping() ? index : Math.max(0, Math.min(count() - 1, index));
|
|
504
|
+
|
|
505
|
+
/** Where page `index` rests (px). */
|
|
506
|
+
function offsetOf(index: number): number {
|
|
507
|
+
if (isChildPages() && childOffsets != null) {
|
|
508
|
+
return childOffsets[
|
|
509
|
+
Math.max(0, Math.min(childOffsets.length - 1, index))
|
|
510
|
+
];
|
|
511
|
+
}
|
|
512
|
+
return index * stride();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Where `position` falls, in pages: an index plus the fraction of the way
|
|
517
|
+
* to the next page (past either end, in the end page's spacing).
|
|
518
|
+
*/
|
|
519
|
+
function pageAt(position: number): number {
|
|
520
|
+
if (!(isChildPages() && childOffsets != null)) {
|
|
521
|
+
return position / stride();
|
|
522
|
+
}
|
|
523
|
+
const at = childOffsets;
|
|
524
|
+
const n = at.length;
|
|
525
|
+
if (n === 1) {
|
|
526
|
+
return (position - at[0]) / (pageWidth > 0 ? pageWidth : 1);
|
|
527
|
+
}
|
|
528
|
+
if (position < at[0]) {
|
|
529
|
+
return (position - at[0]) / (at[1] - at[0]);
|
|
530
|
+
}
|
|
531
|
+
for (let i = 0; i < n - 1; i++) {
|
|
532
|
+
if (position < at[i + 1]) {
|
|
533
|
+
return i + (position - at[i]) / (at[i + 1] - at[i]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return n - 1 + (position - at[n - 1]) / (at[n - 1] - at[n - 2]);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The page nearest `position`, or the target before anything is measured. */
|
|
540
|
+
function nearestPage(position: number): number {
|
|
541
|
+
if (!isChildPages() && !(stride() > 0)) {
|
|
542
|
+
return target;
|
|
543
|
+
}
|
|
544
|
+
return Math.round(pageAt(position));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The page a release from `position` at `velocity` settles on (UIKit's
|
|
549
|
+
* rule, pageForRelease), in children pages' own spacing.
|
|
550
|
+
*/
|
|
551
|
+
function releasePage(
|
|
552
|
+
position: number,
|
|
553
|
+
velocity: number,
|
|
554
|
+
startIndex: number,
|
|
555
|
+
): number {
|
|
556
|
+
const pages = isChildPages() && childOffsets != null;
|
|
557
|
+
return pageForRelease({
|
|
558
|
+
position: pages ? pageAt(position) : position,
|
|
559
|
+
velocity,
|
|
560
|
+
stride: pages ? 1 : stride(),
|
|
561
|
+
count: count(),
|
|
562
|
+
startIndex,
|
|
563
|
+
loop: isLooping(),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Children pages: each child's start edge, from the track's start. The
|
|
569
|
+
* track's own translate moves both alike, so it cancels out. Each offset is
|
|
570
|
+
* clamped to the travel that keeps the track's end at or past the end of
|
|
571
|
+
* the viewport's content box. Children clamped onto one offset share its
|
|
572
|
+
* page.
|
|
573
|
+
*
|
|
574
|
+
* Rects are read in the track's own CSS pixels, which its `translate` moves
|
|
575
|
+
* in. An ancestor's `zoom` scales the rects, so the pager divides out that
|
|
576
|
+
* scale: the rect's width over the track's used width.
|
|
577
|
+
*/
|
|
578
|
+
function measureChildOffsets(): ReadonlyArray<number> {
|
|
579
|
+
const track = trackElement;
|
|
580
|
+
if (viewport == null || track == null || track.children.length === 0) {
|
|
581
|
+
return [0];
|
|
582
|
+
}
|
|
583
|
+
const box = track.getBoundingClientRect();
|
|
584
|
+
const used = Number.parseFloat(getComputedStyle(track).width);
|
|
585
|
+
const scale = used > 0 && box.width > 0 ? box.width / used : 1;
|
|
586
|
+
const style = getComputedStyle(viewport);
|
|
587
|
+
const inner =
|
|
588
|
+
viewport.clientWidth -
|
|
589
|
+
(Number.parseFloat(style.paddingLeft) || 0) -
|
|
590
|
+
(Number.parseFloat(style.paddingRight) || 0);
|
|
591
|
+
const starts: number[] = [];
|
|
592
|
+
let extent = 0;
|
|
593
|
+
for (const child of Array.from(track.children)) {
|
|
594
|
+
const rect = child.getBoundingClientRect();
|
|
595
|
+
starts.push(
|
|
596
|
+
(isRtl ? box.right - rect.right : rect.left - box.left) / scale,
|
|
597
|
+
);
|
|
598
|
+
extent = Math.max(
|
|
599
|
+
extent,
|
|
600
|
+
(isRtl ? box.right - rect.left : rect.right - box.left) / scale,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const end = Math.max(0, extent - inner);
|
|
604
|
+
const offsets: number[] = [];
|
|
605
|
+
for (const start of starts) {
|
|
606
|
+
const offset = Math.min(Math.max(start, 0), end);
|
|
607
|
+
if (offsets.length === 0 || offset > offsets[offsets.length - 1] + 0.5) {
|
|
608
|
+
offsets.push(offset);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return offsets.length > 0 ? offsets : [0];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// The page the track rests on or is heading for (unbounded when looping),
|
|
615
|
+
// and the one it last rested on.
|
|
616
|
+
let target = clampIndex(Math.round(options.defaultIndex ?? 0));
|
|
617
|
+
let settled = target;
|
|
618
|
+
let settleHandle: MotionHandle | null = null;
|
|
619
|
+
let isSwipeSettle = false;
|
|
620
|
+
let settleToken = 0;
|
|
621
|
+
let isDragging = false;
|
|
622
|
+
|
|
623
|
+
let dragStart = 0;
|
|
624
|
+
let dragStartIndex = 0;
|
|
625
|
+
let dragPosition = 0;
|
|
626
|
+
let suppressCancelSettle = false;
|
|
627
|
+
let catchTimer: unknown = null;
|
|
628
|
+
// Whether the finger now holding the track stopped a settle (not one that
|
|
629
|
+
// ended on its own inside the catch window).
|
|
630
|
+
let caughtSettle = false;
|
|
631
|
+
|
|
632
|
+
const pan = new PagerPanGestureRecognizer({
|
|
633
|
+
...options.panOptions,
|
|
634
|
+
// Published, so app code can relate its own recognizers to the paging
|
|
635
|
+
// pan (gesture delegates). Instances are told apart by their element.
|
|
636
|
+
name: options.panOptions?.name ?? 'pager',
|
|
637
|
+
direction: 'horizontal',
|
|
638
|
+
axisRatio: options.panOptions?.axisRatio ?? paging.panAxisRatio,
|
|
639
|
+
// On the web the pan follows a trackpad's scroll along its axis and never
|
|
640
|
+
// a mouse wheel's.
|
|
641
|
+
allowedScrollTypes: options.panOptions?.allowedScrollTypes ?? [
|
|
642
|
+
'continuous',
|
|
643
|
+
],
|
|
644
|
+
shouldBegin: recognizer => {
|
|
645
|
+
if (options.panOptions?.shouldBegin?.(recognizer) === false) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
const dx = recognizer.totalTranslation().x;
|
|
649
|
+
const direction = dx < 0 ? 'left' : 'right';
|
|
650
|
+
return options.shouldBeginPan?.(direction) ?? true;
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Catches a settling track when a finger lands on it. It begins on the
|
|
655
|
+
// touch itself, alongside the pan, only while the track moves on its own
|
|
656
|
+
// and the pan is on.
|
|
657
|
+
const catcher = new SettleCatcher({
|
|
658
|
+
minimumPressDuration: 0,
|
|
659
|
+
allowableMovement: Number.POSITIVE_INFINITY,
|
|
660
|
+
// Its own hold does nothing, so it brings no selection lock: a finger on
|
|
661
|
+
// a resting track keeps the browser's native long press. Stopping a
|
|
662
|
+
// settle does something; `holdSelection` locks then.
|
|
663
|
+
selectionLock: null,
|
|
664
|
+
shouldBegin: () => settleHandle != null && !isDragging && pan.isEnabled,
|
|
665
|
+
});
|
|
666
|
+
catcher.recognizeSimultaneouslyWith(pan);
|
|
667
|
+
|
|
668
|
+
function positionToX(position: number): number {
|
|
669
|
+
const x = sign() * position;
|
|
670
|
+
return x === 0 ? 0 : x;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function presentation(): number {
|
|
674
|
+
if (animator == null) {
|
|
675
|
+
return offsetOf(target);
|
|
676
|
+
}
|
|
677
|
+
return sign() * animator.get('x');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function clearCatchTimer(): void {
|
|
681
|
+
if (catchTimer != null) {
|
|
682
|
+
clearTimer(catchTimer);
|
|
683
|
+
catchTimer = null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function stopSettle(): void {
|
|
688
|
+
clearCatchTimer();
|
|
689
|
+
flushReport();
|
|
690
|
+
isSwipeSettle = false;
|
|
691
|
+
if (settleHandle != null) {
|
|
692
|
+
settleToken += 1;
|
|
693
|
+
settleHandle = null;
|
|
694
|
+
animator?.stop(['x']);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function wrapped(index: number): number {
|
|
699
|
+
return isLooping() ? mod(index, count()) : index;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// The owner passes its own updates (setOptions, a controlled goTo) from an
|
|
703
|
+
// Effect. The callbacks they cause run from a frame callback, never inside
|
|
704
|
+
// that Effect. There, a callback that sets state would update the owner
|
|
705
|
+
// during its own commit. A detach drops the callbacks still waiting.
|
|
706
|
+
let deferDepth = 0;
|
|
707
|
+
let deferred: (() => void)[] = [];
|
|
708
|
+
let deferredToken = 0;
|
|
709
|
+
|
|
710
|
+
function deliver(callback: () => void): void {
|
|
711
|
+
if (deferDepth > 0) {
|
|
712
|
+
deferred.push(callback);
|
|
713
|
+
} else {
|
|
714
|
+
callback();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function deferringCallbacks(work: () => void): void {
|
|
719
|
+
deferDepth += 1;
|
|
720
|
+
try {
|
|
721
|
+
work();
|
|
722
|
+
} finally {
|
|
723
|
+
deferDepth -= 1;
|
|
724
|
+
if (deferDepth === 0 && deferred.length > 0) {
|
|
725
|
+
const calls = deferred;
|
|
726
|
+
deferred = [];
|
|
727
|
+
const token = deferredToken;
|
|
728
|
+
requestFrame(() => {
|
|
729
|
+
if (token === deferredToken) {
|
|
730
|
+
for (const call of calls) {
|
|
731
|
+
call();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function dropDeferred(): void {
|
|
740
|
+
deferred = [];
|
|
741
|
+
deferredToken += 1;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// A release's report waits for its settle to reach the compositor.
|
|
745
|
+
let reported = wrapped(target);
|
|
746
|
+
let pendingReport: PagerChangeReason | null = null;
|
|
747
|
+
let reportToken = 0;
|
|
748
|
+
|
|
749
|
+
function report(reason: PagerChangeReason): void {
|
|
750
|
+
pendingReport = null;
|
|
751
|
+
reportToken += 1;
|
|
752
|
+
const index = wrapped(target);
|
|
753
|
+
if (index !== reported) {
|
|
754
|
+
reported = index;
|
|
755
|
+
// A controlled move (the owner's `index`, or a `count` that clamps the
|
|
756
|
+
// page) is the owner's own change: it is not reported back.
|
|
757
|
+
if (reason !== 'controlled') {
|
|
758
|
+
deliver(() => options.onIndexChange?.(index, reason));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function flushReport(): void {
|
|
764
|
+
if (pendingReport != null) {
|
|
765
|
+
report(pendingReport);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Forgets a release's report still waiting: the pager is detaching, or the
|
|
771
|
+
* owner's own (controlled) move replaces the page it chose. The page last
|
|
772
|
+
* reported stays as it was.
|
|
773
|
+
*/
|
|
774
|
+
function dropReport(): void {
|
|
775
|
+
pendingReport = null;
|
|
776
|
+
reportToken += 1;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* A release reports its page once the settle reaches the compositor, two
|
|
781
|
+
* frames after the frame it started in. In Mobile Safari, a main-thread
|
|
782
|
+
* block that starts within about two frames of the touch freezes a motion
|
|
783
|
+
* that the touch's handler started. The owner's render of the new page is
|
|
784
|
+
* such a block. UIKit tells its delegate at the lift
|
|
785
|
+
* (`scrollViewWillEndDragging`), and the page is known then too (`index`).
|
|
786
|
+
*/
|
|
787
|
+
function setTarget(
|
|
788
|
+
next: number,
|
|
789
|
+
reason: PagerChangeReason,
|
|
790
|
+
whenMoving = false,
|
|
791
|
+
): void {
|
|
792
|
+
target = next;
|
|
793
|
+
if (whenMoving) {
|
|
794
|
+
pendingReport = reason;
|
|
795
|
+
} else {
|
|
796
|
+
report(reason);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Runs `callback` in the `frames`-th animation frame after the frame that a
|
|
802
|
+
* motion started in. Call it when the motion's start resolves.
|
|
803
|
+
*
|
|
804
|
+
* WebKit resolves an animation's start inside that frame's rendering
|
|
805
|
+
* update, before the frame's animation-frame callbacks. No task runs inside
|
|
806
|
+
* a rendering update. So a first frame that runs before a task queued at
|
|
807
|
+
* the resolution is the start's own frame, and it does not count.
|
|
808
|
+
* Timestamps cannot tell: WebKit stamps that frame's callbacks 0–4 ms after
|
|
809
|
+
* the animation's start time, as far apart as two frames at 240 Hz. Where
|
|
810
|
+
* the start resolves after its frame, as in Chromium, the task runs before
|
|
811
|
+
* the next frame. The report then comes a frame later, never earlier.
|
|
812
|
+
*/
|
|
813
|
+
function afterFramesPastStart(frames: number, callback: () => void): void {
|
|
814
|
+
let isPastStartFrame = false;
|
|
815
|
+
setTimer(() => {
|
|
816
|
+
isPastStartFrame = true;
|
|
817
|
+
}, 0);
|
|
818
|
+
const onFrame = (left: number) => {
|
|
819
|
+
const remaining = isPastStartFrame ? left - 1 : left;
|
|
820
|
+
isPastStartFrame = true;
|
|
821
|
+
if (remaining <= 0) {
|
|
822
|
+
callback();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
requestFrame(() => onFrame(remaining));
|
|
826
|
+
};
|
|
827
|
+
requestFrame(() => onFrame(frames));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function rest(): void {
|
|
831
|
+
flushReport();
|
|
832
|
+
isSwipeSettle = false;
|
|
833
|
+
const previous = wrapped(settled);
|
|
834
|
+
settled = target;
|
|
835
|
+
const index = wrapped(settled);
|
|
836
|
+
deliver(() => options.onSettle?.(index, previous));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Plays `curve` on the track. Under Reduce Motion, the animator replaces the
|
|
841
|
+
* travel. A settle that a touch's release starts (`touchRelease`)
|
|
842
|
+
* cross-fades on any pointer. Any other motion, such as `goTo` or a mouse's
|
|
843
|
+
* or a pen's release, cross-fades on a coarse pointer. It lands at once on
|
|
844
|
+
* any other pointer.
|
|
845
|
+
*/
|
|
846
|
+
function play(curve: MotionCurve, touchRelease = false): void {
|
|
847
|
+
isSwipeSettle = false;
|
|
848
|
+
if (animator == null) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
follow(animator.play({x: toTrackCurve(curve, sign())}, {touchRelease}));
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function follow(handle: MotionHandle): void {
|
|
855
|
+
const token = ++settleToken;
|
|
856
|
+
settleHandle = handle;
|
|
857
|
+
if (pendingReport != null) {
|
|
858
|
+
const reportAt = reportToken;
|
|
859
|
+
void handle.started.then(() =>
|
|
860
|
+
afterFramesPastStart(2, () => {
|
|
861
|
+
if (reportAt === reportToken) {
|
|
862
|
+
flushReport();
|
|
863
|
+
}
|
|
864
|
+
}),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
void handle.finished.then(finished => {
|
|
868
|
+
if (token !== settleToken) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
settleHandle = null;
|
|
872
|
+
if (finished) {
|
|
873
|
+
rest();
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The settle after a pointer lifts (a release, or a hold let go);
|
|
880
|
+
* `touchRelease` when that pointer was a touch.
|
|
881
|
+
*/
|
|
882
|
+
function settleFrom(
|
|
883
|
+
position: number,
|
|
884
|
+
velocity: number,
|
|
885
|
+
touchRelease: boolean,
|
|
886
|
+
): void {
|
|
887
|
+
const to = offsetOf(target);
|
|
888
|
+
// UIKit's integrator, as a playable curve (touch motion stays internal).
|
|
889
|
+
const curve = solvePaging({from: position, velocity, to});
|
|
890
|
+
if (curve.steps === 0) {
|
|
891
|
+
animator?.set({x: positionToX(to)});
|
|
892
|
+
rest();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
play(curve, touchRelease);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function measure(): void {
|
|
899
|
+
if (viewport == null) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const width = viewport.clientWidth;
|
|
903
|
+
const rtl = isRtlElement(viewport);
|
|
904
|
+
// A new writing direction mirrors the track, so it re-places it too.
|
|
905
|
+
let changed = width !== pageWidth || rtl !== isRtl;
|
|
906
|
+
isRtl = rtl;
|
|
907
|
+
pageWidth = width;
|
|
908
|
+
if (isChildPages()) {
|
|
909
|
+
const offsets = measureChildOffsets();
|
|
910
|
+
if (!sameOffsets(offsets, childOffsets)) {
|
|
911
|
+
childOffsets = offsets;
|
|
912
|
+
changed = true;
|
|
913
|
+
const pageCount = offsets.length;
|
|
914
|
+
deliver(() => options.onPagesChange?.(pageCount));
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (!changed) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (!isDragging && !isLooping() && target > count() - 1) {
|
|
921
|
+
// Fewer pages (children clamped onto one): the last one, as a smaller
|
|
922
|
+
// count clamps. A loop's target is unbounded, so it keeps it.
|
|
923
|
+
goTo(count() - 1, {animated: false, reason: 'controlled'});
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// A resize lands on the target page at its new offset (a settle in
|
|
927
|
+
// flight ends there), as a paging scroll view does on rotation. A finger
|
|
928
|
+
// that holds a caught track keeps it; its lift settles on the new offsets.
|
|
929
|
+
if (!isDragging && !caughtSettle) {
|
|
930
|
+
stopSettle();
|
|
931
|
+
animator?.set({x: positionToX(offsetOf(target))});
|
|
932
|
+
if (settled !== target) {
|
|
933
|
+
rest();
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function onPan(recognizer: PanGestureRecognizer): void {
|
|
939
|
+
const state = recognizer.state;
|
|
940
|
+
if (state === 'began') {
|
|
941
|
+
// Read before measure(), which can stop the settle on a resize.
|
|
942
|
+
const wasSwipeSettle = settleHandle != null && isSwipeSettle;
|
|
943
|
+
measure();
|
|
944
|
+
clearCatchTimer();
|
|
945
|
+
const current = presentation();
|
|
946
|
+
stopSettle();
|
|
947
|
+
isDragging = true;
|
|
948
|
+
// A track caught past an end is shown rubber-banded: the drag starts
|
|
949
|
+
// from its unbanded position, so the first move keeps it under the
|
|
950
|
+
// finger.
|
|
951
|
+
dragStart = isLooping()
|
|
952
|
+
? current
|
|
953
|
+
: rubberBandUnclamp(
|
|
954
|
+
current,
|
|
955
|
+
offsetOf(0),
|
|
956
|
+
offsetOf(count() - 1),
|
|
957
|
+
pageWidth > 0 ? pageWidth : 1,
|
|
958
|
+
);
|
|
959
|
+
dragPosition = current;
|
|
960
|
+
// A trackpad swipe that lands while the last swipe's settle runs
|
|
961
|
+
// begins from that settle's page, so back-to-back swipes page on. Any
|
|
962
|
+
// other motion, such as a goTo, hands over the page on screen.
|
|
963
|
+
dragStartIndex =
|
|
964
|
+
wasSwipeSettle && isScrollContact(recognizer)
|
|
965
|
+
? target
|
|
966
|
+
: nearestPage(current);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (state === 'changed') {
|
|
970
|
+
const dx = recognizer.translation().x;
|
|
971
|
+
// Finger right (dx > 0) moves content toward earlier pages in LTR.
|
|
972
|
+
let raw = dragStart + sign() * dx;
|
|
973
|
+
if (isScrollContact(recognizer) && pageWidth > 0) {
|
|
974
|
+
// A trackpad swipe moves the track at most one page from its start
|
|
975
|
+
// page, across a loop too. The window also holds where the swipe
|
|
976
|
+
// began, so a swipe that catches a longer move starts from the
|
|
977
|
+
// screen. A pager that does not loop keeps the rubber band of its
|
|
978
|
+
// first and last pages below.
|
|
979
|
+
const looping = isLooping();
|
|
980
|
+
const before =
|
|
981
|
+
looping || dragStartIndex > 0
|
|
982
|
+
? offsetOf(dragStartIndex - 1)
|
|
983
|
+
: -Infinity;
|
|
984
|
+
const after =
|
|
985
|
+
looping || dragStartIndex < count() - 1
|
|
986
|
+
? offsetOf(dragStartIndex + 1)
|
|
987
|
+
: Infinity;
|
|
988
|
+
raw = Math.min(
|
|
989
|
+
Math.max(raw, Math.min(before, after, dragStart)),
|
|
990
|
+
Math.max(before, after, dragStart),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
dragPosition = isLooping()
|
|
994
|
+
? raw
|
|
995
|
+
: rubberBandClamp(
|
|
996
|
+
raw,
|
|
997
|
+
offsetOf(0),
|
|
998
|
+
offsetOf(count() - 1),
|
|
999
|
+
pageWidth > 0 ? pageWidth : 1,
|
|
1000
|
+
);
|
|
1001
|
+
animator?.set({x: positionToX(dragPosition)});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (state === 'ended' || state === 'cancelled') {
|
|
1005
|
+
isDragging = false;
|
|
1006
|
+
if (state === 'cancelled' && suppressCancelSettle) {
|
|
1007
|
+
suppressCancelSettle = false;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
// A still finger releases at 0, where UIScrollView keeps the speed.
|
|
1011
|
+
let velocity =
|
|
1012
|
+
state === 'ended' ? sign() * recognizer.scrollVelocity().x : 0;
|
|
1013
|
+
// A trackpad swipe that moved under a tenth of a page from where it
|
|
1014
|
+
// began, in the pages' own spacing, releases still at any speed. Such a
|
|
1015
|
+
// speed spike is not a flick. A finger's and a mouse's flick keep their
|
|
1016
|
+
// speed.
|
|
1017
|
+
if (
|
|
1018
|
+
isScrollContact(recognizer) &&
|
|
1019
|
+
Math.abs(pageAt(dragPosition) - pageAt(dragStart)) <
|
|
1020
|
+
STILL_RELEASE_FRACTION
|
|
1021
|
+
) {
|
|
1022
|
+
velocity = 0;
|
|
1023
|
+
}
|
|
1024
|
+
const next = releasePage(dragPosition, velocity, dragStartIndex);
|
|
1025
|
+
setTarget(next, 'drag', true);
|
|
1026
|
+
settleFrom(
|
|
1027
|
+
dragPosition,
|
|
1028
|
+
pagingSettleVelocity(velocity),
|
|
1029
|
+
pan.pointerType === 'touch',
|
|
1030
|
+
);
|
|
1031
|
+
isSwipeSettle = settleHandle != null && isScrollContact(recognizer);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// While a finger holds a caught track, the browser's long-press text
|
|
1036
|
+
// selection and callout stay off. In UIKit, the touch that stops a scroll
|
|
1037
|
+
// view does not reach its content. The gesture system's shared lock is
|
|
1038
|
+
// counted per element. So this lock and the gesture host's own lock for the
|
|
1039
|
+
// same contact release in either order.
|
|
1040
|
+
let releaseSelection: (() => void) | null = null;
|
|
1041
|
+
|
|
1042
|
+
function holdSelection(on: boolean): void {
|
|
1043
|
+
if (on && releaseSelection == null && trackElement != null) {
|
|
1044
|
+
if (locksSelectionFor(catcher.pointerType ?? 'touch')) {
|
|
1045
|
+
releaseSelection = lockSelection([trackElement], null);
|
|
1046
|
+
}
|
|
1047
|
+
} else if (!on && releaseSelection != null) {
|
|
1048
|
+
releaseSelection();
|
|
1049
|
+
releaseSelection = null;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function onCatch(recognizer: LongPressGestureRecognizer): void {
|
|
1054
|
+
if (recognizer.state === 'began') {
|
|
1055
|
+
holdSelection(true);
|
|
1056
|
+
clearCatchTimer();
|
|
1057
|
+
caughtSettle = false;
|
|
1058
|
+
catchTimer = setTimer(() => {
|
|
1059
|
+
catchTimer = null;
|
|
1060
|
+
if (settleHandle != null && !isDragging) {
|
|
1061
|
+
stopSettle();
|
|
1062
|
+
caughtSettle = true;
|
|
1063
|
+
}
|
|
1064
|
+
}, paging.catchStopMs);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (recognizer.state === 'ended' || recognizer.state === 'cancelled') {
|
|
1068
|
+
holdSelection(false);
|
|
1069
|
+
if (catchTimer != null) {
|
|
1070
|
+
// Lifted before the catch took hold: the settle runs on.
|
|
1071
|
+
clearCatchTimer();
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const wasCaught = caughtSettle;
|
|
1075
|
+
caughtSettle = false;
|
|
1076
|
+
if (
|
|
1077
|
+
wasCaught &&
|
|
1078
|
+
!isDragging &&
|
|
1079
|
+
settleHandle == null &&
|
|
1080
|
+
animator != null
|
|
1081
|
+
) {
|
|
1082
|
+
// Held, then lifted without dragging: settle on the nearest page.
|
|
1083
|
+
const position = presentation();
|
|
1084
|
+
const next = releasePage(position, 0, nearestPage(position));
|
|
1085
|
+
setTarget(next, 'drag', true);
|
|
1086
|
+
settleFrom(position, 0, catcher.pointerType === 'touch');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function goTo(index: number, goToOptions: GoToOptions = {}): void {
|
|
1092
|
+
if (goToOptions.reason === 'controlled' && deferDepth === 0) {
|
|
1093
|
+
// The owner's own move: what it calls back waits for a frame.
|
|
1094
|
+
deferringCallbacks(() => goTo(index, goToOptions));
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const {animated = true, reason = 'goTo'} = goToOptions;
|
|
1098
|
+
let next = Math.round(index);
|
|
1099
|
+
if (isLooping()) {
|
|
1100
|
+
next = nearestLoopCopy(next, target, count());
|
|
1101
|
+
} else {
|
|
1102
|
+
next = clampIndex(next);
|
|
1103
|
+
}
|
|
1104
|
+
if (isDragging) {
|
|
1105
|
+
suppressCancelSettle = true;
|
|
1106
|
+
isDragging = false;
|
|
1107
|
+
pan.cancel();
|
|
1108
|
+
}
|
|
1109
|
+
const from = presentation();
|
|
1110
|
+
// A move this one retargets hands on its velocity, from its curve.
|
|
1111
|
+
const velocity =
|
|
1112
|
+
settleHandle != null && animator != null
|
|
1113
|
+
? sign() * animator.getVelocity('x')
|
|
1114
|
+
: undefined;
|
|
1115
|
+
if (reason === 'controlled') {
|
|
1116
|
+
dropReport();
|
|
1117
|
+
}
|
|
1118
|
+
stopSettle();
|
|
1119
|
+
setTarget(next, reason);
|
|
1120
|
+
const to = offsetOf(next);
|
|
1121
|
+
if (!animated || animator == null || from === to) {
|
|
1122
|
+
animator?.set({x: positionToX(to)});
|
|
1123
|
+
rest();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
play(
|
|
1127
|
+
solveAnimatedPageScroll({
|
|
1128
|
+
from,
|
|
1129
|
+
to,
|
|
1130
|
+
durationMs: resolveMotion(
|
|
1131
|
+
trackElement,
|
|
1132
|
+
paging.animatedScroll,
|
|
1133
|
+
'durationMs',
|
|
1134
|
+
),
|
|
1135
|
+
curve: resolveMotion(trackElement, paging.animatedScroll.curve),
|
|
1136
|
+
velocity,
|
|
1137
|
+
}),
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function setOptions(update: Partial<PagerOptions>): void {
|
|
1142
|
+
deferringCallbacks(() => applyOptions(update));
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function applyOptions(update: Partial<PagerOptions>): void {
|
|
1146
|
+
const wasLooping = isLooping();
|
|
1147
|
+
const wasChildPages = isChildPages();
|
|
1148
|
+
const previousCount = count();
|
|
1149
|
+
const previousOffset = offsetOf(target);
|
|
1150
|
+
// A loop's indexes are unbounded. So the page shown and the page last at
|
|
1151
|
+
// rest are read under the old count, before the options change.
|
|
1152
|
+
const shownBefore = wasLooping ? mod(target, previousCount) : target;
|
|
1153
|
+
const settledBefore = wasLooping ? mod(settled, previousCount) : settled;
|
|
1154
|
+
options = {...options, ...update};
|
|
1155
|
+
if (isChildPages() !== wasChildPages) {
|
|
1156
|
+
syncTrackObserver();
|
|
1157
|
+
}
|
|
1158
|
+
if (isChildPages() && viewport != null && !isDragging) {
|
|
1159
|
+
// The children may have changed with the options (a count, a gap).
|
|
1160
|
+
const offsets = measureChildOffsets();
|
|
1161
|
+
if (!sameOffsets(offsets, childOffsets)) {
|
|
1162
|
+
childOffsets = offsets;
|
|
1163
|
+
const pageCount = offsets.length;
|
|
1164
|
+
deliver(() => options.onPagesChange?.(pageCount));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (
|
|
1168
|
+
(wasLooping || isLooping()) &&
|
|
1169
|
+
(wasLooping !== isLooping() || count() !== previousCount)
|
|
1170
|
+
) {
|
|
1171
|
+
if (target < 0 || target > Math.min(previousCount, count()) - 1) {
|
|
1172
|
+
rewrap(shownBefore, settledBefore);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
// The target's slot shows the same page. Only the page last at rest
|
|
1176
|
+
// can be a virtual index, so it is read under the old count.
|
|
1177
|
+
settled = settledBefore;
|
|
1178
|
+
}
|
|
1179
|
+
if (!isLooping() && (target < 0 || target > count() - 1)) {
|
|
1180
|
+
// A smaller `count` clamps.
|
|
1181
|
+
goTo(clampIndex(target), {animated: false, reason: 'controlled'});
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (offsetOf(target) !== previousOffset && !isDragging) {
|
|
1185
|
+
// A new gap (or children): the target page at its new offset (a settle
|
|
1186
|
+
// ends there).
|
|
1187
|
+
const wasSettling = settleHandle != null;
|
|
1188
|
+
stopSettle();
|
|
1189
|
+
animator?.set({x: positionToX(offsetOf(target))});
|
|
1190
|
+
if (wasSettling || settled !== target) {
|
|
1191
|
+
rest();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Turning `loop` off, or a loop's new count, where the target's slot now
|
|
1198
|
+
* shows another page. The page shown keeps its index, and a smaller count
|
|
1199
|
+
* clamps it. A release's waiting report names its page under the old count.
|
|
1200
|
+
* It reaches the owner only while that page exists.
|
|
1201
|
+
*/
|
|
1202
|
+
function rewrap(shownBefore: number, settledBefore: number): void {
|
|
1203
|
+
const next = Math.min(shownBefore, count() - 1);
|
|
1204
|
+
const waiting = pendingReport;
|
|
1205
|
+
pendingReport = null;
|
|
1206
|
+
if (isDragging) {
|
|
1207
|
+
suppressCancelSettle = true;
|
|
1208
|
+
isDragging = false;
|
|
1209
|
+
pan.cancel();
|
|
1210
|
+
}
|
|
1211
|
+
const wasSettling = settleHandle != null;
|
|
1212
|
+
stopSettle();
|
|
1213
|
+
if (waiting != null && shownBefore === next) {
|
|
1214
|
+
target = next;
|
|
1215
|
+
report(waiting);
|
|
1216
|
+
}
|
|
1217
|
+
setTarget(next, 'controlled');
|
|
1218
|
+
animator?.set({x: positionToX(offsetOf(next))});
|
|
1219
|
+
settled = next;
|
|
1220
|
+
if (wasSettling || settledBefore !== next) {
|
|
1221
|
+
deliver(() => options.onSettle?.(next, settledBefore));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
let detachAll: (() => void) | null = null;
|
|
1226
|
+
// What a detach carries to the next attach: a release's report that still
|
|
1227
|
+
// waits, and whether the detach stopped a motion before the track rested.
|
|
1228
|
+
let carriedReport: PagerChangeReason | null = null;
|
|
1229
|
+
let carriedEnd = false;
|
|
1230
|
+
|
|
1231
|
+
// Children pages also move when the track's own size changes, as when a
|
|
1232
|
+
// child is added, removed or resized. So the track is observed while the
|
|
1233
|
+
// pages are its children, from the attach or the options change that makes
|
|
1234
|
+
// them so.
|
|
1235
|
+
let unobserveTrack: () => void = () => {};
|
|
1236
|
+
function syncTrackObserver(): void {
|
|
1237
|
+
unobserveTrack();
|
|
1238
|
+
unobserveTrack =
|
|
1239
|
+
isChildPages() && trackElement != null && detachAll != null
|
|
1240
|
+
? observeResize(trackElement, measure)
|
|
1241
|
+
: () => {};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function attach(nextViewport: HTMLElement, nextTrack: HTMLElement) {
|
|
1245
|
+
detachAll?.();
|
|
1246
|
+
view = nextViewport.ownerDocument.defaultView;
|
|
1247
|
+
viewport = nextViewport;
|
|
1248
|
+
trackElement = nextTrack;
|
|
1249
|
+
animator = environment.animator ?? animatorFor(nextTrack);
|
|
1250
|
+
pageWidth = 0;
|
|
1251
|
+
isRtl = isRtlElement(nextViewport);
|
|
1252
|
+
pageWidth = nextViewport.clientWidth;
|
|
1253
|
+
if (isChildPages()) {
|
|
1254
|
+
childOffsets = measureChildOffsets();
|
|
1255
|
+
target = clampIndex(target);
|
|
1256
|
+
settled = target;
|
|
1257
|
+
}
|
|
1258
|
+
animator.set({x: positionToX(offsetOf(target))});
|
|
1259
|
+
// No pointer check: a mouse and a trackpad page it too.
|
|
1260
|
+
const removePanListener = pan.addListener(onPan);
|
|
1261
|
+
const removeCatchListener = catcher.addListener(onCatch);
|
|
1262
|
+
const host = environment.host ?? getGestureHost(nextViewport.ownerDocument);
|
|
1263
|
+
const detachPan = host.attach(nextViewport, pan);
|
|
1264
|
+
// The catcher sits on the track, so its long-press styles (no callout, no
|
|
1265
|
+
// selection) stay off the pages' text. The viewport's touch-action
|
|
1266
|
+
// already covers the touches that land on the track.
|
|
1267
|
+
const detachCatch = host.attach(nextTrack, catcher, {
|
|
1268
|
+
manageStyle: false,
|
|
1269
|
+
});
|
|
1270
|
+
const detachGestures = () => {
|
|
1271
|
+
detachCatch();
|
|
1272
|
+
detachPan();
|
|
1273
|
+
removeCatchListener();
|
|
1274
|
+
removePanListener();
|
|
1275
|
+
};
|
|
1276
|
+
const unobserve = observeResize(nextViewport, measure);
|
|
1277
|
+
detachAll = () => {
|
|
1278
|
+
unobserve();
|
|
1279
|
+
unobserveTrack();
|
|
1280
|
+
unobserveTrack = () => {};
|
|
1281
|
+
// A drag that the detach ends stays on its start page. The report and
|
|
1282
|
+
// the end of a motion that the detach stops wait for the next attach,
|
|
1283
|
+
// so an unmount calls nothing.
|
|
1284
|
+
if (isDragging) {
|
|
1285
|
+
suppressCancelSettle = true;
|
|
1286
|
+
}
|
|
1287
|
+
deferringCallbacks(() => detachGestures());
|
|
1288
|
+
suppressCancelSettle = false;
|
|
1289
|
+
carriedReport = pendingReport;
|
|
1290
|
+
carriedEnd = target !== settled;
|
|
1291
|
+
dropReport();
|
|
1292
|
+
stopSettle();
|
|
1293
|
+
dropDeferred();
|
|
1294
|
+
holdSelection(false);
|
|
1295
|
+
isDragging = false;
|
|
1296
|
+
detachAll = null;
|
|
1297
|
+
};
|
|
1298
|
+
syncTrackObserver();
|
|
1299
|
+
// A motion that the last detach stopped ends here: its report, if it
|
|
1300
|
+
// still waited, and its onSettle go out from a frame.
|
|
1301
|
+
if (carriedEnd && target !== settled) {
|
|
1302
|
+
const report = carriedReport;
|
|
1303
|
+
deferringCallbacks(() => {
|
|
1304
|
+
pendingReport = report;
|
|
1305
|
+
rest();
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
carriedReport = null;
|
|
1309
|
+
carriedEnd = false;
|
|
1310
|
+
return () => detachAll?.();
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
return {
|
|
1314
|
+
pan,
|
|
1315
|
+
get index() {
|
|
1316
|
+
return wrapped(target);
|
|
1317
|
+
},
|
|
1318
|
+
get virtualIndex() {
|
|
1319
|
+
return target;
|
|
1320
|
+
},
|
|
1321
|
+
get pageCount() {
|
|
1322
|
+
return count();
|
|
1323
|
+
},
|
|
1324
|
+
get isSettling() {
|
|
1325
|
+
return settleHandle != null;
|
|
1326
|
+
},
|
|
1327
|
+
get isDragging() {
|
|
1328
|
+
return isDragging;
|
|
1329
|
+
},
|
|
1330
|
+
presentation,
|
|
1331
|
+
pageOffset: offsetOf,
|
|
1332
|
+
nearestPage: (position: number) => clampIndex(nearestPage(position)),
|
|
1333
|
+
goTo,
|
|
1334
|
+
setOptions,
|
|
1335
|
+
setPanEnabled(enabled: boolean) {
|
|
1336
|
+
deferringCallbacks(() => pan.setEnabled(enabled));
|
|
1337
|
+
},
|
|
1338
|
+
attach,
|
|
1339
|
+
dispose() {
|
|
1340
|
+
detachAll?.();
|
|
1341
|
+
},
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
export interface UsePagerOptions extends PagerOptions {
|
|
1346
|
+
/** The gesture surface: the clipped box the pages show through. */
|
|
1347
|
+
viewportRef: RefObject<HTMLElement | null>;
|
|
1348
|
+
/** The row of pages the pager translates. */
|
|
1349
|
+
trackRef: RefObject<HTMLElement | null>;
|
|
1350
|
+
/**
|
|
1351
|
+
* Controlled target page; a change animates to it (reason 'controlled'),
|
|
1352
|
+
* and a page the pager reports that the owner does not take returns it
|
|
1353
|
+
* here.
|
|
1354
|
+
*/
|
|
1355
|
+
index?: number;
|
|
1356
|
+
/** Live toggle for the pan; turning it off mid-drag cancels the drag. */
|
|
1357
|
+
isPanEnabled?: boolean;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* A pager on two elements. Callbacks are read through a ref, so passing new
|
|
1362
|
+
* functions each render costs nothing; a gesture never renders.
|
|
1363
|
+
*
|
|
1364
|
+
* @example
|
|
1365
|
+
* ```
|
|
1366
|
+
* const pager = usePager({viewportRef, trackRef, count: photos.length, gap: 40,
|
|
1367
|
+
* onIndexChange: setCounter});
|
|
1368
|
+
* <div ref={viewportRef} style={{overflow: 'hidden'}}>
|
|
1369
|
+
* <div ref={trackRef} style={{display: 'flex', gap: 40}}>…pages…</div>
|
|
1370
|
+
* </div>
|
|
1371
|
+
* ```
|
|
1372
|
+
*/
|
|
1373
|
+
export function usePager({
|
|
1374
|
+
viewportRef,
|
|
1375
|
+
trackRef,
|
|
1376
|
+
index,
|
|
1377
|
+
isPanEnabled = true,
|
|
1378
|
+
...options
|
|
1379
|
+
}: UsePagerOptions): Pager {
|
|
1380
|
+
const latestRef = useRef(options);
|
|
1381
|
+
// The controlled `index` of the last commit, read when the pager reports.
|
|
1382
|
+
const indexRef = useRef(index);
|
|
1383
|
+
// Syncs the callback and controlled-index refs with the newest render; the
|
|
1384
|
+
// pager's callbacks read them.
|
|
1385
|
+
useIsomorphicLayoutEffect(() => {
|
|
1386
|
+
latestRef.current = options;
|
|
1387
|
+
indexRef.current = index;
|
|
1388
|
+
});
|
|
1389
|
+
// Bumped when the pager reports a page while `index` is controlled. The
|
|
1390
|
+
// render it causes is batched with the owner's own update. If the owner kept
|
|
1391
|
+
// `index`, that render returns the pager to it (the controlled sync below).
|
|
1392
|
+
const [reported, setReported] = useState(0);
|
|
1393
|
+
const [pager] = useState(() =>
|
|
1394
|
+
createPager({
|
|
1395
|
+
...options,
|
|
1396
|
+
defaultIndex: index ?? options.defaultIndex,
|
|
1397
|
+
onIndexChange: (i, reason) => {
|
|
1398
|
+
latestRef.current.onIndexChange?.(i, reason);
|
|
1399
|
+
if (indexRef.current != null) {
|
|
1400
|
+
setReported(n => n + 1);
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
onSettle: (i, previous) => latestRef.current.onSettle?.(i, previous),
|
|
1404
|
+
onPagesChange: pageCount => latestRef.current.onPagesChange?.(pageCount),
|
|
1405
|
+
shouldBeginPan: direction =>
|
|
1406
|
+
latestRef.current.shouldBeginPan?.(direction) ?? true,
|
|
1407
|
+
}),
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
// Synchronizes the pager (the track's animation and the gesture host) with
|
|
1411
|
+
// these props. A clamp it causes is not reported back (reason 'controlled').
|
|
1412
|
+
const {count, gap, loop, pages} = options;
|
|
1413
|
+
useIsomorphicLayoutEffect(() => {
|
|
1414
|
+
pager.setOptions({count, gap, loop, pages});
|
|
1415
|
+
}, [pager, count, gap, loop, pages]);
|
|
1416
|
+
|
|
1417
|
+
// Attaches the pager to the viewport and track nodes, and again whenever
|
|
1418
|
+
// either node is replaced; detaches at unmount.
|
|
1419
|
+
useAttachmentPair(
|
|
1420
|
+
viewportRef,
|
|
1421
|
+
trackRef,
|
|
1422
|
+
(viewport, track) => pager.attach(viewport, track),
|
|
1423
|
+
pager,
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
// Synchronizes the pan recognizer (the gesture host) with isPanEnabled; what
|
|
1427
|
+
// a drag it ends calls back waits for the next frame, after this Effect.
|
|
1428
|
+
useEffect(() => {
|
|
1429
|
+
pager.setPanEnabled(isPanEnabled);
|
|
1430
|
+
}, [pager, isPanEnabled]);
|
|
1431
|
+
|
|
1432
|
+
// Synchronizes the pager, an imperative system outside React (the track's
|
|
1433
|
+
// animation and its gesture recognizers), with a controlled `index`. A new
|
|
1434
|
+
// `index` moves the pager there. So does the render after a page that it
|
|
1435
|
+
// reported and the owner did not take, such as a refused swipe. A
|
|
1436
|
+
// controlled input returns to its value the same way. The move is the
|
|
1437
|
+
// owner's own, so it is not reported back. It moves only while the two
|
|
1438
|
+
// differ.
|
|
1439
|
+
useEffect(() => {
|
|
1440
|
+
if (index != null && index !== pager.index) {
|
|
1441
|
+
pager.goTo(index, {reason: 'controlled'});
|
|
1442
|
+
}
|
|
1443
|
+
}, [pager, index, reported]);
|
|
1444
|
+
|
|
1445
|
+
return pager;
|
|
1446
|
+
}
|