@astryxdesign/core 0.7.6 → 0.7.7-canary.36f8ea1

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.
Files changed (101) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  5. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  6. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  7. package/dist/Chat/ChatComposerInput.js +2 -0
  8. package/dist/Chat/ChatMessage.js +2 -2
  9. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  10. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  11. package/dist/Drawer/Drawer.d.ts.map +1 -1
  12. package/dist/Drawer/Drawer.js +6 -0
  13. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  14. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  15. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  16. package/dist/Link/Link.d.ts.map +1 -1
  17. package/dist/Link/Link.js +1 -0
  18. package/dist/Selector/Selector.d.ts.map +1 -1
  19. package/dist/Selector/Selector.js +25 -13
  20. package/dist/Slider/Slider.d.ts.map +1 -1
  21. package/dist/Slider/Slider.js +404 -131
  22. package/dist/Slider/sliderTouchController.d.ts +189 -0
  23. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  24. package/dist/Slider/sliderTouchController.js +1017 -0
  25. package/dist/Slider/useSliderTouch.d.ts +22 -0
  26. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  27. package/dist/Slider/useSliderTouch.js +56 -0
  28. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  29. package/dist/Typeahead/BaseTypeahead.js +10 -8
  30. package/dist/astryx.css +33 -6
  31. package/dist/gestures/GestureArena.d.ts.map +1 -1
  32. package/dist/gestures/GestureArena.js +4 -1
  33. package/dist/gestures/GestureHost.d.ts +72 -2
  34. package/dist/gestures/GestureHost.d.ts.map +1 -1
  35. package/dist/gestures/GestureHost.js +643 -5
  36. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  37. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  38. package/dist/gestures/GestureRecognizer.js +12 -0
  39. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  40. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  41. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  42. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  43. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  44. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  45. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  46. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  47. package/dist/gestures/nativeScroll.d.ts +16 -2
  48. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  49. package/dist/gestures/nativeScroll.js +39 -8
  50. package/dist/gestures/recognizerInternals.d.ts +4 -2
  51. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  52. package/dist/gestures/types.d.ts +11 -0
  53. package/dist/gestures/types.d.ts.map +1 -1
  54. package/dist/gestures/types.js +7 -0
  55. package/dist/gestures/wheelSegmenter.d.ts +45 -0
  56. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  57. package/dist/gestures/wheelSegmenter.js +98 -0
  58. package/dist/motion/animator.d.ts.map +1 -1
  59. package/dist/motion/animator.js +77 -14
  60. package/dist/motion/uikit/index.d.ts +2 -1
  61. package/dist/motion/uikit/index.d.ts.map +1 -1
  62. package/dist/motion/uikit/index.js +2 -1
  63. package/dist/motion/uikit/slider.d.ts +97 -0
  64. package/dist/motion/uikit/slider.d.ts.map +1 -0
  65. package/dist/motion/uikit/slider.js +111 -0
  66. package/dist/motion/uikit/springs.d.ts +46 -1
  67. package/dist/motion/uikit/springs.d.ts.map +1 -1
  68. package/dist/motion/uikit/springs.js +48 -4
  69. package/package.json +5 -5
  70. package/src/AppShell/AppShell.doc.mjs +10 -0
  71. package/src/AppShell/AppShell.tsx +48 -2
  72. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  73. package/src/Chat/Chat.doc.mjs +1 -0
  74. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  75. package/src/Chat/ChatComposerInput.tsx +11 -0
  76. package/src/Chat/ChatMessage.tsx +2 -2
  77. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  78. package/src/Drawer/Drawer.tsx +6 -0
  79. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  80. package/src/Link/Link.tsx +3 -0
  81. package/src/Selector/Selector.tsx +29 -2
  82. package/src/Slider/Slider.doc.mjs +11 -7
  83. package/src/Slider/Slider.tsx +539 -132
  84. package/src/Slider/sliderTouchController.ts +1209 -0
  85. package/src/Slider/useSliderTouch.ts +78 -0
  86. package/src/Typeahead/BaseTypeahead.tsx +9 -8
  87. package/src/gestures/GestureArena.ts +1 -0
  88. package/src/gestures/GestureHost.ts +765 -5
  89. package/src/gestures/GestureRecognizer.ts +28 -0
  90. package/src/gestures/PanGestureRecognizer.ts +8 -0
  91. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  92. package/src/gestures/README.md +63 -0
  93. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  94. package/src/gestures/nativeScroll.ts +49 -9
  95. package/src/gestures/recognizerInternals.ts +9 -2
  96. package/src/gestures/types.ts +12 -0
  97. package/src/gestures/wheelSegmenter.ts +114 -0
  98. package/src/motion/animator.ts +115 -19
  99. package/src/motion/uikit/index.ts +2 -1
  100. package/src/motion/uikit/slider.ts +105 -0
  101. package/src/motion/uikit/springs.ts +37 -2
@@ -36,6 +36,7 @@ import type {
36
36
  GestureTouch,
37
37
  Point,
38
38
  PointerKind,
39
+ ScrollType,
39
40
  } from './types';
40
41
  import {DEFAULT_ENVIRONMENT} from './types';
41
42
 
@@ -81,6 +82,16 @@ export interface GestureRecognizerOptions<R = GestureRecognizer> {
81
82
  delaysTouchesEnded?: boolean;
82
83
  /** Pointer kinds this recognizer listens to (UIKit's `allowedTouchTypes`). @default all */
83
84
  allowedPointerTypes?: ReadonlyArray<PointerKind>;
85
+ /**
86
+ * Scroll input this recognizer takes, as UIKit's `allowedScrollTypesMask`.
87
+ * A pan follows a trackpad's two-finger scroll (`continuous`), never a
88
+ * mouse wheel's notches. It moves as a contact with the content, along its
89
+ * own axis, after the scrollers under the pointer. A pinch takes a
90
+ * ctrl-wheel of either, as a trackpad pinches.
91
+ * @default none; a pinch takes both, the screen-edge pan none whatever a
92
+ * caller passes
93
+ */
94
+ allowedScrollTypes?: ReadonlyArray<ScrollType>;
84
95
  /** Ignore a touch of a different kind than the ones already tracked. @default true */
85
96
  requiresExclusiveTouchType?: boolean;
86
97
  /**
@@ -202,6 +213,8 @@ export abstract class GestureRecognizer {
202
213
  delaysTouchesBegan: boolean;
203
214
  delaysTouchesEnded: boolean;
204
215
  allowedPointerTypes: ReadonlySet<PointerKind> | null;
216
+ /** See GestureRecognizerOptions.allowedScrollTypes. */
217
+ readonly allowedScrollTypes: ReadonlySet<ScrollType>;
205
218
  requiresExclusiveTouchType: boolean;
206
219
  /**
207
220
  * The element (or any object) the recognizer is attached to. The DOM host
@@ -247,6 +260,9 @@ export abstract class GestureRecognizer {
247
260
  options.allowedPointerTypes == null
248
261
  ? null
249
262
  : new Set(options.allowedPointerTypes);
263
+ this.allowedScrollTypes = new Set(
264
+ options.allowedScrollTypes ?? this.defaultScrollTypes,
265
+ );
250
266
  this.requiresExclusiveTouchType =
251
267
  options.requiresExclusiveTouchType ?? true;
252
268
  }
@@ -479,6 +495,11 @@ export abstract class GestureRecognizer {
479
495
  /** Clear per-gesture state before the next attempt. */
480
496
  protected reset(): void {}
481
497
 
498
+ /** The scroll types taken when the options name none (UIKit's: none). */
499
+ protected get defaultScrollTypes(): ReadonlyArray<ScrollType> {
500
+ return [];
501
+ }
502
+
482
503
  /**
483
504
  * Whether a touch may join. The base checks enabled, pointer kind and
484
505
  * exclusivity of kind; subclasses narrow it (a one-finger pan ignores a
@@ -488,6 +509,13 @@ export abstract class GestureRecognizer {
488
509
  if (!this.isEnabled) {
489
510
  return false;
490
511
  }
512
+ if (touch.scrollType != null) {
513
+ // Scroll input is no pointer: only the scroll types allowed count.
514
+ return (
515
+ this.allowedScrollTypes.has(touch.scrollType) &&
516
+ (this.options.shouldReceiveTouch?.(this, touch) ?? true)
517
+ );
518
+ }
491
519
  if (
492
520
  this.allowedPointerTypes != null &&
493
521
  !this.allowedPointerTypes.has(touch.pointerType)
@@ -73,6 +73,14 @@ export interface PanGestureRecognizerOptions extends GestureRecognizerOptions<Pa
73
73
  * at the start only (UIScrollView's directional lock).
74
74
  */
75
75
  lockAxis?: boolean;
76
+ /**
77
+ * A trackpad's two-finger scroll (`continuous`) drives the pan where its
78
+ * owner allows it. It moves as a contact with the content, along its own
79
+ * axis, after the scrollers under the pointer. A mouse wheel's notches
80
+ * never drive a pan; they scroll what is under the pointer.
81
+ * @default [] (none)
82
+ */
83
+ allowedScrollTypes?: ReadonlyArray<'continuous'>;
76
84
  /** Begin only when the native scrollers under the touch are at the edge. */
77
85
  scrollEdge?: boolean;
78
86
  }
@@ -27,7 +27,12 @@ import {
27
27
  type GestureRecognizerOptions,
28
28
  type NativeTouchBehavior,
29
29
  } from './GestureRecognizer';
30
- import type {GestureEventContext, GestureTouch, Point} from './types';
30
+ import type {
31
+ GestureEventContext,
32
+ GestureTouch,
33
+ Point,
34
+ ScrollType,
35
+ } from './types';
31
36
  import {VelocityTracker} from './VelocityTracker';
32
37
 
33
38
  export interface PinchGestureRecognizerOptions extends GestureRecognizerOptions<PinchGestureRecognizer> {
@@ -51,6 +56,16 @@ export class PinchGestureRecognizer extends GestureRecognizer {
51
56
  this.hysteresis = options.hysteresis ?? UIKIT_PINCH.hysteresis.value;
52
57
  }
53
58
 
59
+ /** @internal The host's brand: a ctrl-wheel drives a pinch (recognizerInternals). */
60
+ protected get family(): 'pinch' {
61
+ return 'pinch';
62
+ }
63
+
64
+ /** A ctrl-wheel drives a pinch, from a trackpad or a mouse. */
65
+ protected override get defaultScrollTypes(): ReadonlyArray<ScrollType> {
66
+ return ['continuous', 'discrete'];
67
+ }
68
+
54
69
  /** Current span over the span the fingers started at. */
55
70
  get scale(): number {
56
71
  return this.scaleValue;
@@ -452,6 +452,69 @@ anywhere (often on a plain row no recognizer is on) fails a pan limited to
452
452
  one finger, so the two fingers zoom the page (WCAG 1.4.4) instead of the pan
453
453
  claiming them midway.
454
454
 
455
+ ## Wheel and trackpad
456
+
457
+ A recognizer takes scroll input only where it allows that scroll type
458
+ (`allowedScrollTypes`, UIKit's `allowedScrollTypesMask`): `continuous`
459
+ for a trackpad's two-finger scroll or pinch, `discrete` for a mouse wheel's
460
+ notches. A pan follows a trackpad, never a mouse wheel (`spec:AST-094` FR43,
461
+ DEC-24): it allows only `continuous`, and none by default, so it takes a
462
+ trackpad's scroll only where its owner allows it, its element then getting
463
+ the one wheel listener, and a mouse wheel's notches always scroll what is
464
+ under the pointer. A pinch allows both, for a ctrl-wheel from a trackpad or
465
+ a mouse. The screen-edge pan takes none, as it takes no mouse, trackpad or
466
+ pen (FR43). A control's thumb, a slider's or a switch's, tracks as a control,
467
+ not a pan, and takes no wheel, so a page scroll over it never changes its
468
+ value (DEC-23). The web reports no wheel phases or device, so the deltas
469
+ tell, and what they cannot tell apart reads as a mouse wheel: a line- or
470
+ page-mode delta; a scroll with Shift held (Shift+wheel); a pixel delta a hair
471
+ off a whole pixel (a mouse wheel on Windows); a scroll with no legacy `wheelDelta` (an engine that
472
+ sends none); a delta that is not a third of its legacy `wheelDelta`; or a 120
473
+ tick over a 40 px delta. A trackpad's precise deltas, a third of their legacy
474
+ `wheelDelta` (Chromium and WebKit), read as a trackpad.
475
+
476
+ A wheel gesture drives a pan as a contact moving with the content, along the
477
+ pan's own axis, and only once every scroller between the pointer and the
478
+ pan's element is at its edge that way, decided at the gesture's first wheel:
479
+ a wheel the pan does not take is never prevented. A watcher
480
+ (`claimsTouches: false`) never takes one, and a gesture whose first wheel
481
+ another handler already prevented, or that cannot be cancelled, stays the
482
+ page's. A scroll-edge pan (a pull to
483
+ refresh) takes one only if no scroller under the pointer could scroll for it,
484
+ its own scroll owner and the page included, as its finger's rule reads them
485
+ (`spec:AST-094` FR19); `touch-action` narrows only a finger's pan, so a wheel
486
+ reads every scroller past it. A ctrl-wheel (a trackpad
487
+ pinch, or Control and a mouse wheel) drives a pinch, whose scale follows
488
+ `exp(-deltaY / 100)`; a pinch takes it by default and ends 100 ms after its
489
+ last wheel. While the fingers move, a trackpad scroll ends 180 ms after its
490
+ last wheel vertically and 220 ms horizontally, whoever keeps it (a pan, a
491
+ scroller under the pointer, or the page), as a slow drag pauses between
492
+ wheels (DEC-24). A gesture's release is never stamped
493
+ later than an input that ends it sooner (FR16).
494
+
495
+ The web reports no trackpad contact or phase either, so one splitter reads
496
+ every trackpad stream (`wheelSegmenter.ts`, DEC-24): at the fourth or later
497
+ delta of a shrinking run, once the run has lost a fifth of its size, the
498
+ fingers lifted and momentum began. Until it decides,
499
+ the shrinking deltas wait, and a pan it follows moves on at the fingers' last
500
+ speed, never past what that speed projects. At the lift the pan keeps that
501
+ travel and lets go there, still moving at that speed, and the momentum after
502
+ it never moves it; when the fingers carry on instead, their moves absorb
503
+ what the pan showed past their travel, or short of it, so it never steps
504
+ back and ends on their travel. Fingers that stop release still, once the end
505
+ time passes. In momentum, a reversal, a delta that grows
506
+ against the decay, or a 50 ms pause starts a new gesture. A finger, mouse or
507
+ pen that comes down ends it, and a wheel claim keeps the page's text
508
+ selection. A gesture a
509
+ recognizer took is latched to it, as a browser latches a scroller: while it
510
+ lasts, the host hears every step first, through the shared input stream's
511
+ window-capture listener, wherever it lands, so a pan whose element moves from
512
+ under a still pointer keeps the gesture. A page that loses focus or hides
513
+ cancels a trackpad pan a recognizer still drives, as it cancels every touch;
514
+ the rest of that stream is the page's. A ctrl-wheel pinch keeps its stream,
515
+ so the page never zooms for it, and a pan its lift released keeps its
516
+ momentum latched until the stream ends.
517
+
455
518
  ## Time
456
519
 
457
520
  Every input carries its own timestamp (`event.timeStamp`), and timers are
@@ -71,12 +71,12 @@ interface EdgeLayout {
71
71
  }
72
72
 
73
73
  /**
74
- * A screen-edge pan is a finger's: it takes no `allowedPointerTypes`
75
- * (`spec:AST-094` FR43).
74
+ * A screen-edge pan is a finger's: it takes no `allowedPointerTypes` and no
75
+ * `allowedScrollTypes`, so neither a mouse, a trackpad nor a pen drives it.
76
76
  */
77
77
  export interface ScreenEdgePanGestureRecognizerOptions extends Omit<
78
78
  PanGestureRecognizerOptions,
79
- 'direction' | 'allowedPointerTypes'
79
+ 'direction' | 'allowedPointerTypes' | 'allowedScrollTypes'
80
80
  > {
81
81
  /** The edges a touch may start from. @default ['leading'] */
82
82
  edges?: ReadonlyArray<ScreenEdge>;
@@ -109,9 +109,11 @@ export class ScreenEdgePanGestureRecognizer extends PanGestureRecognizer {
109
109
  maximumNumberOfTouches: 1,
110
110
  hysteresis: UIKIT_SCREEN_EDGE_PAN.hysteresis.value,
111
111
  ...options,
112
- // A finger's only: under a mouse, a trackpad or a pen the screen-edge
113
- // pan stays off (`spec:AST-094` FR43), whatever a caller passes.
112
+ // A finger's only (`spec:AST-094` FR43). Under a mouse, a trackpad or
113
+ // a pen, the screen-edge pan stays off, whatever a caller passes. It
114
+ // takes no wheel or trackpad scrolling.
114
115
  allowedPointerTypes: ['touch'],
116
+ allowedScrollTypes: [],
115
117
  });
116
118
  this.edges = options.edges ?? ['leading'];
117
119
  this.edgeRegion =
@@ -4,7 +4,8 @@
4
4
  * @file nativeScroll.ts
5
5
  * @input An element a touch landed on
6
6
  * @output Exports createNativeScrollProbe (the NativeScrollProbe a host gives
7
- * each touch) and parseTouchAction
7
+ * each touch, and each wheel gesture without the touch-action narrowing)
8
+ * and parseTouchAction
8
9
  * @position DOM half of the scroll coordination in packages/core/src/gestures
9
10
  *
10
11
  * Answers one question for a recognizer without letting it touch the DOM:
@@ -82,7 +83,7 @@ interface Chain {
82
83
  readonly allowsPan: boolean;
83
84
  }
84
85
 
85
- function buildChain(target: Element): Chain {
86
+ function buildChain(target: Element, readsTouchAction: boolean): Chain {
86
87
  const doc = target.ownerDocument;
87
88
  const view = doc.defaultView;
88
89
  if (view == null) {
@@ -103,9 +104,11 @@ function buildChain(target: Element): Chain {
103
104
  continue;
104
105
  }
105
106
  const style = view.getComputedStyle(node);
106
- const pans = parseTouchAction(style.touchAction || 'auto');
107
- allowX &&= pans.x;
108
- allowY &&= pans.y;
107
+ if (readsTouchAction) {
108
+ const pans = parseTouchAction(style.touchAction || 'auto');
109
+ allowX &&= pans.x;
110
+ allowY &&= pans.y;
111
+ }
109
112
  const isRoot = node === root || node === doc.documentElement;
110
113
  if (isRoot) {
111
114
  reachedRoot = true;
@@ -196,7 +199,10 @@ function canMoveY(element: Element, dy: number): boolean {
196
199
  element.scrollHeight - element.clientHeight - EDGE_TOLERANCE + 0.5;
197
200
  }
198
201
 
199
- function canMoveX(entry: ChainEntry, dx: number): boolean {
202
+ function canMoveX(
203
+ entry: Pick<ChainEntry, 'element' | 'isRtl'>,
204
+ dx: number,
205
+ ): boolean {
200
206
  const {element} = entry;
201
207
  const overflow = element.scrollWidth - element.clientWidth;
202
208
  if (overflow <= EDGE_TOLERANCE) {
@@ -210,13 +216,47 @@ function canMoveX(entry: ChainEntry, dx: number): boolean {
210
216
  : element.scrollLeft < max - EDGE_TOLERANCE + 0.5;
211
217
  }
212
218
 
213
- /** A probe of the native scrollers under `target`. */
214
- export function createNativeScrollProbe(target: unknown): NativeScrollProbe {
219
+ /**
220
+ * Whether `element` itself scrolls natively now for a finger moving by
221
+ * (dx, dy). It uses the probe's own test of each scroller: overflow that
222
+ * scrolls on that axis, and room past the edge tolerance. The position must
223
+ * also be short of the end it moves toward, a right-to-left range included.
224
+ * A wheel moves the content the other way, so pass its deltas negated.
225
+ */
226
+ export function canElementScroll(
227
+ element: Element,
228
+ dx: number,
229
+ dy: number,
230
+ ): boolean {
231
+ const view = element.ownerDocument.defaultView;
232
+ if (view == null) {
233
+ return false;
234
+ }
235
+ const style = view.getComputedStyle(element);
236
+ if (dy !== 0 && SCROLLABLE.has(style.overflowY) && canMoveY(element, dy)) {
237
+ return true;
238
+ }
239
+ return (
240
+ dx !== 0 &&
241
+ SCROLLABLE.has(style.overflowX) &&
242
+ canMoveX({element, isRtl: isRtlElement(element as HTMLElement)}, dx)
243
+ );
244
+ }
245
+
246
+ /**
247
+ * A probe of the native scrollers under `target`. A wheel scrolls whatever
248
+ * `touch-action` says, which narrows only a finger's pan: a wheel's probe
249
+ * passes `readsTouchAction: false`.
250
+ */
251
+ export function createNativeScrollProbe(
252
+ target: unknown,
253
+ {readsTouchAction = true}: {readsTouchAction?: boolean} = {},
254
+ ): NativeScrollProbe {
215
255
  let chain: Chain | null = null;
216
256
  const getChain = (): ChainEntry[] => {
217
257
  chain ??=
218
258
  target instanceof Element
219
- ? buildChain(target)
259
+ ? buildChain(target, readsTouchAction)
220
260
  : {entries: [], allowsPan: false};
221
261
  return chain.entries;
222
262
  };
@@ -20,7 +20,12 @@
20
20
 
21
21
  import type {GestureArena} from './GestureArena';
22
22
  import type {GestureRecognizer} from './GestureRecognizer';
23
- import type {GestureEventContext, GestureState, GestureTouch} from './types';
23
+ import type {
24
+ GestureEventContext,
25
+ GestureState,
26
+ GestureTouch,
27
+ Point,
28
+ } from './types';
24
29
 
25
30
  /**
26
31
  * The kinds of recognizer the host and touch-action treat specially. Each of
@@ -28,12 +33,14 @@ import type {GestureEventContext, GestureState, GestureTouch} from './types';
28
33
  * never imports a recognizer class: a page ships only the recognizers it
29
34
  * constructs.
30
35
  */
31
- export type RecognizerFamily = 'tap' | 'longPress' | 'pan';
36
+ export type RecognizerFamily = 'tap' | 'longPress' | 'pan' | 'pinch';
32
37
 
33
38
  /** The members of a recognizer only the gesture system calls. */
34
39
  export interface RecognizerInternals {
35
40
  /** The recognizer's family, when the host treats it specially. */
36
41
  readonly family?: RecognizerFamily;
42
+ /** A pan's direction rule for a translation, `axisRatio` included. */
43
+ classify?(translation: Point): 'begin' | 'fail' | 'wait';
37
44
  arena: GestureArena | null;
38
45
  pending: 'began' | 'ended' | null;
39
46
  readonly seenTouchIds: Set<number>;
@@ -21,6 +21,14 @@ export type GestureState =
21
21
  /** What made the contact. A pen on an iPad is `pen`, not `touch`. */
22
22
  export type PointerKind = 'touch' | 'mouse' | 'pen';
23
23
 
24
+ /**
25
+ * Scroll input a recognizer can take, as UIKit's `UIScrollTypeMask`: a
26
+ * trackpad's two-finger scroll or pinch (`continuous`), or a mouse wheel's
27
+ * notches (`discrete`). A pan takes only a trackpad's; a pinch takes a
28
+ * ctrl-wheel of either.
29
+ */
30
+ export type ScrollType = 'continuous' | 'discrete';
31
+
24
32
  export interface Point {
25
33
  readonly x: number;
26
34
  readonly y: number;
@@ -85,6 +93,8 @@ export interface GestureTouch {
85
93
  readonly target: unknown;
86
94
  /** The native scrollers under the contact, when the host knows them. */
87
95
  readonly scroll: NativeScrollProbe | null;
96
+ /** Set for a wheel's or a trackpad's scroll input, which is no contact. */
97
+ readonly scrollType?: ScrollType;
88
98
  /** The browser took this contact for its own pan or zoom. */
89
99
  takenByBrowser: boolean;
90
100
  }
@@ -104,6 +114,8 @@ export interface TouchBeganInput extends TouchInput {
104
114
  readonly pointerType: PointerKind;
105
115
  readonly target: unknown;
106
116
  readonly scroll?: NativeScrollProbe | null;
117
+ /** Set for a wheel's or a trackpad's scroll input. */
118
+ readonly scrollType?: ScrollType;
107
119
  }
108
120
 
109
121
  /** What a recognizer is told alongside the touches that changed. */
@@ -0,0 +1,114 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file wheelSegmenter.ts
5
+ * @input A wheel stream's deltas along one axis, with their event times
6
+ * @output Exports WheelSegmenter: splits the stream into gestures and reads
7
+ * a trackpad's lift (the onset of momentum)
8
+ * @position Read by GestureHost.ts's wheel input: the web reports no
9
+ * trackpad contact or phase, so both are inferred.
10
+ */
11
+
12
+ export type WheelBoundary = 'first' | 'idle' | 'reversal' | 'jump' | 'gap';
13
+ export type WheelPhase = 'finger' | 'momentum';
14
+
15
+ export interface WheelSegment {
16
+ /** Why this event starts a new gesture, or null when it continues one. */
17
+ readonly boundary: WheelBoundary | null;
18
+ readonly phase: WheelPhase;
19
+ /** The gesture's number, counting from 1. */
20
+ readonly gesture: number;
21
+ /** This event is where the fingers lifted (momentum begins with it). */
22
+ readonly liftNow: boolean;
23
+ /** This event shrank, so it may be momentum: hold it until known. */
24
+ readonly decaying: boolean;
25
+ }
26
+
27
+ /** Momentum arrives at frame rate: a pause this long ends it. */
28
+ const MOMENTUM_GAP_MS = 50;
29
+ /** Shrinking deltas in a row before the lift is read. */
30
+ const LIFT_RUN = 4;
31
+ /** And how much of its size the run has lost by then. */
32
+ const LIFT_DECAY = 0.8;
33
+
34
+ /**
35
+ * A trackpad gesture has a finger phase and, after a fling, a momentum phase.
36
+ * In the finger phase, deltas come while the fingers move, with pauses when
37
+ * they move slowly. In the momentum phase, deltas come at frame rate and
38
+ * shrink smoothly. The lift is the start of a run of shrinking deltas that
39
+ * has lost a fifth of its size. In the finger phase, a pause of `idleMs`
40
+ * ends the gesture. In the momentum phase, a reversal, a jump up against the
41
+ * decay, or a pause of 50 ms starts a new gesture. Momentum never pauses,
42
+ * reverses or grows.
43
+ */
44
+ export class WheelSegmenter {
45
+ gesture = 0;
46
+ phase: WheelPhase = 'finger';
47
+ private last = -Infinity;
48
+ private previous = 0;
49
+ private sign = 0;
50
+ private count = 0;
51
+ private run = 0;
52
+ private runStart = 0;
53
+
54
+ push(time: number, delta: number, idleMs: number): WheelSegment {
55
+ const size = Math.abs(delta);
56
+ const sign = Math.sign(delta);
57
+ const gap = time - this.last;
58
+ let boundary: WheelBoundary | null = null;
59
+ if (this.count === 0) {
60
+ boundary = this.gesture === 0 ? 'first' : 'idle';
61
+ } else if (this.phase === 'finger' && gap >= idleMs) {
62
+ boundary = 'idle';
63
+ } else if (this.phase === 'momentum') {
64
+ if (sign !== 0 && this.sign !== 0 && sign !== this.sign) {
65
+ boundary = 'reversal';
66
+ } else if (gap >= MOMENTUM_GAP_MS) {
67
+ boundary = 'gap';
68
+ } else if (size >= 2 && size > this.previous * 1.3 + 0.5) {
69
+ boundary = 'jump';
70
+ }
71
+ }
72
+ if (boundary != null) {
73
+ this.gesture += 1;
74
+ this.phase = 'finger';
75
+ this.count = 0;
76
+ this.run = 0;
77
+ }
78
+ let liftNow = false;
79
+ let decaying = false;
80
+ if (this.phase === 'finger' && this.count >= 3 && size > 0) {
81
+ decaying = size <= this.previous * 0.995 && size >= this.previous * 0.5;
82
+ if (decaying) {
83
+ if (this.run === 0) {
84
+ this.runStart = this.previous;
85
+ }
86
+ this.run += 1;
87
+ } else {
88
+ this.run = 0;
89
+ }
90
+ if (this.run >= LIFT_RUN && size <= this.runStart * LIFT_DECAY) {
91
+ this.phase = 'momentum';
92
+ liftNow = true;
93
+ }
94
+ }
95
+ this.count += 1;
96
+ this.previous = size;
97
+ if (sign !== 0) {
98
+ this.sign = sign;
99
+ }
100
+ this.last = time;
101
+ return {
102
+ boundary,
103
+ phase: this.phase,
104
+ gesture: this.gesture,
105
+ liftNow,
106
+ decaying: decaying && !liftNow,
107
+ };
108
+ }
109
+
110
+ /** The gesture ended on its own (its timer): the next event starts one. */
111
+ end(): void {
112
+ this.count = 0;
113
+ }
114
+ }
@@ -517,6 +517,14 @@ interface StartGroup {
517
517
  madeInFrame?: boolean;
518
518
  /** The task it was made in, with every call made in that task. */
519
519
  task?: TaskMark;
520
+ /**
521
+ * Per channel, when its animation's first frame ran without a known start.
522
+ * That frame came too late to stand for the start, and the browser had not
523
+ * told it yet. The compositor starts the animation within about a frame of
524
+ * this time. Until the browser tells its start, a read of the channel takes
525
+ * the curve from here.
526
+ */
527
+ latest?: Map<Channel, number>;
520
528
  /** A later call in the same task that took over its curves. */
521
529
  adoptedBy?: StartGroup;
522
530
  readonly resolve: (start: number) => void;
@@ -1070,16 +1078,42 @@ export function createAnimator(
1070
1078
  };
1071
1079
 
1072
1080
  /**
1073
- * The value on screen at `time`. A curve the browser has not started is at
1074
- * its start up to now; a later time reads the curve from its provisional
1075
- * start, the model's best guess until the browser's start moves it (a
1076
- * companion that samples a curve ahead follows its shape).
1081
+ * A held curve's start once a frame dated its call, as the browser's start
1082
+ * moves it: by that frame's lead over the call's own time.
1083
+ */
1084
+ const datedFromLatest = (
1085
+ segment: Segment,
1086
+ latest: number | undefined,
1087
+ ): {latest: number; start: number} | null =>
1088
+ latest == null || segment.group == null
1089
+ ? null
1090
+ : {latest, start: segment.start + latest - segment.group.provisional};
1091
+
1092
+ /**
1093
+ * The value on screen at `time`. Until the browser reports its start, a
1094
+ * held curve shows its first value. A frame that ran without that start
1095
+ * dates the curve. Without that frame, a later time reads from the
1096
+ * provisional start, so a companion that samples ahead follows the curve's
1097
+ * shape.
1077
1098
  */
1078
1099
  const valueAt = (property: MotionProperty, time: number): number => {
1079
1100
  const segment = segments.get(property);
1080
- if (segment != null && isHeld(segment) && time <= now()) {
1101
+ if (segment != null && isHeld(segment)) {
1102
+ // The compositor starts the call near its first frame. The curve is
1103
+ // dated as the browser's start would date it. A curve that joined by an
1104
+ // older clock keeps its lead over the call's own time.
1105
+ const latest = segment.group?.latest?.get(CHANNEL_OF[property]);
1106
+ const dated = datedFromLatest(segment, latest);
1107
+ if (dated != null) {
1108
+ const elapsed = Math.max(time, dated.latest) - dated.start;
1109
+ return elapsed >= segment.duration
1110
+ ? segment.target
1111
+ : segment.at(elapsed);
1112
+ }
1081
1113
  // Nothing has moved on screen until the browser starts the call.
1082
- return segment.at(0);
1114
+ if (time <= now()) {
1115
+ return segment.at(0);
1116
+ }
1083
1117
  }
1084
1118
  return curveAt(property, time);
1085
1119
  };
@@ -1087,10 +1121,22 @@ export function createAnimator(
1087
1121
  const velocityAt = (property: MotionProperty, time: number): number => {
1088
1122
  const held = segments.get(property);
1089
1123
  if (held != null && isHeld(held)) {
1090
- // Still at its start on screen, but moving in the model: a retarget
1091
- // carries the velocity the curve has reached by now, so a flick whose
1092
- // moves all land before the browser starts any keeps its speed.
1093
- return held.speed(Math.max(0, time - held.start));
1124
+ // Still at its start on screen, but moving in the model. A retarget
1125
+ // carries the velocity the curve has reached by now. So a flick whose
1126
+ // moves all land before the browser starts any keeps its speed. Past
1127
+ // its first frame, it reads from that frame, as its value does.
1128
+ const dated = datedFromLatest(
1129
+ held,
1130
+ held.group?.latest?.get(CHANNEL_OF[property]),
1131
+ );
1132
+ return held.speed(
1133
+ Math.max(
1134
+ 0,
1135
+ dated != null && time > dated.latest
1136
+ ? time - dated.start
1137
+ : time - held.start,
1138
+ ),
1139
+ );
1094
1140
  }
1095
1141
  const segment = segmentAt(property, time);
1096
1142
  if (segment == null || time < segment.start) {
@@ -1276,6 +1322,35 @@ export function createAnimator(
1276
1322
  }
1277
1323
  }
1278
1324
  const moving = properties.filter(p => segments.has(p));
1325
+ // An explicit call can rebuild a channel while one of its curves still
1326
+ // waits for the browser's start. The animation then joins the current
1327
+ // call, or else that waiting call, with no start of its own.
1328
+ const waiting =
1329
+ callGroup ??
1330
+ moving
1331
+ .map(p => (segments.get(p) as Segment).group)
1332
+ .find(group => group != null && !group.resolved) ??
1333
+ null;
1334
+ const rebuiltAfterFirstFrame = waiting?.framed === true;
1335
+ // The new animation starts at the frame that commits it, from its first
1336
+ // keyframe. That holds for a waiting call it joins after that call's
1337
+ // first frame, and for every waiting call it takes a curve from. So this
1338
+ // channel's reads below, and until that frame, take that first value.
1339
+ const restarted = new Set<StartGroup>();
1340
+ if (rebuiltAfterFirstFrame) {
1341
+ restarted.add(waiting);
1342
+ }
1343
+ if (callGroup != null) {
1344
+ for (const property of moving) {
1345
+ const group = (segments.get(property) as Segment).group;
1346
+ if (group != null && group !== callGroup && !group.resolved) {
1347
+ restarted.add(group);
1348
+ }
1349
+ }
1350
+ }
1351
+ for (const group of restarted) {
1352
+ group.latest?.delete(channel);
1353
+ }
1279
1354
  if (channel === 'translate') {
1280
1355
  // The animation built below starts from the value at `time` (its first
1281
1356
  // keyframe); a rebase or a retarget builds a new one, so a new start.
@@ -1308,15 +1383,6 @@ export function createAnimator(
1308
1383
  previous?.cancel();
1309
1384
  return;
1310
1385
  }
1311
- // A channel rebuilt by an explicit call (set, rebase, a retarget of
1312
- // another axis) while one of its curves still waits for the browser's
1313
- // start keeps that start: no explicit start for this animation.
1314
- const waiting =
1315
- callGroup ??
1316
- moving
1317
- .map(p => (segments.get(p) as Segment).group)
1318
- .find(group => group != null && !group.resolved) ??
1319
- null;
1320
1386
  // The keyframes start where the animation will: at the waiting call's
1321
1387
  // own time, which its start moves to the browser's (resolveGroup), so a
1322
1388
  // write later in the task keeps the screen on the model's clock.
@@ -1431,6 +1497,20 @@ export function createAnimator(
1431
1497
  waiting.channels.add(channel);
1432
1498
  // A cancel before the first frame rejects its ready promise.
1433
1499
  (animation as Partial<Animation>).ready?.catch(() => {});
1500
+ if (rebuiltAfterFirstFrame) {
1501
+ // The frame that commits it dates this channel's start again. The
1502
+ // call's other channels keep their own.
1503
+ requestFrame?.(() => {
1504
+ if (
1505
+ !waiting.resolved &&
1506
+ running.get(channel) === animation &&
1507
+ animation.startTime == null &&
1508
+ animation.playState === 'running'
1509
+ ) {
1510
+ (waiting.latest ??= new Map()).set(channel, now());
1511
+ }
1512
+ });
1513
+ }
1434
1514
  } else {
1435
1515
  // One clock: the keyframes were sampled from `time`.
1436
1516
  animation.startTime = time;
@@ -2135,6 +2215,22 @@ export function createAnimator(
2135
2215
  ) {
2136
2216
  resolveGroup(group, now());
2137
2217
  }
2218
+ // The browser's start is still unread. The compositor starts the call
2219
+ // near this frame, so each moving channel's reads take the curve from
2220
+ // now until the browser tells its start.
2221
+ if (!group.resolved) {
2222
+ for (const channel of group.channels) {
2223
+ const animation = running.get(channel);
2224
+ if (
2225
+ animation != null &&
2226
+ group.animations.includes(animation) &&
2227
+ animation.startTime == null &&
2228
+ animation.playState === 'running'
2229
+ ) {
2230
+ (group.latest ??= new Map()).set(channel, now());
2231
+ }
2232
+ }
2233
+ }
2138
2234
  });
2139
2235
  ready.then(
2140
2236
  () => syncGroup(group),