@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.
Files changed (272) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  7. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  8. package/dist/Breadcrumbs/BreadcrumbItem.js +2 -1
  9. package/dist/Button/Button.d.ts.map +1 -1
  10. package/dist/Button/Button.js +3 -2
  11. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  12. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  13. package/dist/Chat/ChatComposerInput.js +2 -0
  14. package/dist/Chat/ChatMessage.js +2 -2
  15. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  16. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  17. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  18. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  19. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  20. package/dist/DateInput/MonthScroller.js +72 -2
  21. package/dist/DateInput/Wheel.d.ts +17 -1
  22. package/dist/DateInput/Wheel.d.ts.map +1 -1
  23. package/dist/DateInput/Wheel.js +179 -19
  24. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  25. package/dist/DateRangeInput/DateRangeInput.js +63 -49
  26. package/dist/Dialog/Dialog.d.ts.map +1 -1
  27. package/dist/Dialog/Dialog.js +37 -1
  28. package/dist/Drawer/Drawer.d.ts.map +1 -1
  29. package/dist/Drawer/Drawer.js +6 -0
  30. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  31. package/dist/Drawer/useDrawerDialogPresence.js +4 -0
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +20 -4
  34. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  35. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  36. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  37. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  38. package/dist/Field/PanelSearchInput.js +8 -1
  39. package/dist/Field/textEntryTraits.d.ts +6 -6
  40. package/dist/Field/textEntryTraits.js +6 -6
  41. package/dist/Layer/layerClickBoundary.d.ts +7 -1
  42. package/dist/Layer/layerClickBoundary.d.ts.map +1 -1
  43. package/dist/Layer/layerClickBoundary.js +2 -2
  44. package/dist/Layer/usePressPin.d.ts +95 -0
  45. package/dist/Layer/usePressPin.d.ts.map +1 -0
  46. package/dist/Layer/usePressPin.js +365 -0
  47. package/dist/Layer/useTouchTrigger.d.ts +23 -1
  48. package/dist/Layer/useTouchTrigger.d.ts.map +1 -1
  49. package/dist/Layer/useTouchTrigger.js +45 -7
  50. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  51. package/dist/Lightbox/Lightbox.js +2 -1
  52. package/dist/Link/Link.d.ts.map +1 -1
  53. package/dist/Link/Link.js +1 -0
  54. package/dist/Popover/usePopover.d.ts.map +1 -1
  55. package/dist/Popover/usePopover.js +56 -7
  56. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  57. package/dist/PowerSearch/PowerSearch.js +38 -30
  58. package/dist/ProgressBar/ProgressBar.d.ts +1 -1
  59. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  60. package/dist/ProgressBar/ProgressBar.js +52 -14
  61. package/dist/ProgressBar/ProgressBarTouch.d.ts +29 -0
  62. package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -0
  63. package/dist/ProgressBar/ProgressBarTouch.js +91 -0
  64. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  65. package/dist/SegmentedControl/SegmentedControlItem.js +2 -1
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +25 -13
  68. package/dist/Slider/Slider.d.ts.map +1 -1
  69. package/dist/Slider/Slider.js +404 -131
  70. package/dist/Slider/sliderTouchController.d.ts +189 -0
  71. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  72. package/dist/Slider/sliderTouchController.js +1017 -0
  73. package/dist/Slider/useSliderTouch.d.ts +22 -0
  74. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  75. package/dist/Slider/useSliderTouch.js +56 -0
  76. package/dist/Switch/Switch.d.ts +1 -1
  77. package/dist/Switch/Switch.d.ts.map +1 -1
  78. package/dist/Switch/Switch.js +200 -46
  79. package/dist/Switch/switchFill.d.ts +31 -0
  80. package/dist/Switch/switchFill.d.ts.map +1 -0
  81. package/dist/Switch/switchFill.js +88 -0
  82. package/dist/Switch/switchTouch.d.ts +81 -0
  83. package/dist/Switch/switchTouch.d.ts.map +1 -0
  84. package/dist/Switch/switchTouch.js +133 -0
  85. package/dist/Switch/useSwitchTouch.d.ts +285 -0
  86. package/dist/Switch/useSwitchTouch.d.ts.map +1 -0
  87. package/dist/Switch/useSwitchTouch.js +998 -0
  88. package/dist/TabList/Tab.d.ts.map +1 -1
  89. package/dist/TabList/Tab.js +4 -3
  90. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  91. package/dist/Tokenizer/Tokenizer.js +12 -5
  92. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts +3 -0
  93. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -0
  94. package/dist/Tokenizer/TokenizerItemRemovableContext.js +17 -0
  95. package/dist/Tooltip/Tooltip.d.ts +30 -6
  96. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  97. package/dist/Tooltip/Tooltip.js +5 -0
  98. package/dist/Tooltip/index.d.ts +1 -1
  99. package/dist/Tooltip/index.d.ts.map +1 -1
  100. package/dist/Tooltip/useTooltip.d.ts +31 -5
  101. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  102. package/dist/Tooltip/useTooltip.js +59 -18
  103. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  104. package/dist/Typeahead/BaseTypeahead.js +19 -9
  105. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  106. package/dist/Typeahead/Typeahead.js +26 -15
  107. package/dist/astryx.css +91 -8
  108. package/dist/gestures/GestureArena.d.ts +21 -2
  109. package/dist/gestures/GestureArena.d.ts.map +1 -1
  110. package/dist/gestures/GestureArena.js +23 -3
  111. package/dist/gestures/GestureHost.d.ts +123 -3
  112. package/dist/gestures/GestureHost.d.ts.map +1 -1
  113. package/dist/gestures/GestureHost.js +797 -15
  114. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  115. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  116. package/dist/gestures/GestureRecognizer.js +12 -0
  117. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  118. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  119. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  120. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  121. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  122. package/dist/gestures/PressTracker.d.ts +6 -0
  123. package/dist/gestures/PressTracker.d.ts.map +1 -1
  124. package/dist/gestures/PressTracker.js +3 -0
  125. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  126. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  127. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  128. package/dist/gestures/nativeScroll.d.ts +16 -2
  129. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  130. package/dist/gestures/nativeScroll.js +39 -8
  131. package/dist/gestures/recognizerInternals.d.ts +4 -2
  132. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  133. package/dist/gestures/types.d.ts +17 -0
  134. package/dist/gestures/types.d.ts.map +1 -1
  135. package/dist/gestures/types.js +7 -0
  136. package/dist/gestures/usePressTracker.d.ts.map +1 -1
  137. package/dist/gestures/usePressTracker.js +1 -0
  138. package/dist/gestures/wheelSegmenter.d.ts +56 -0
  139. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  140. package/dist/gestures/wheelSegmenter.js +122 -0
  141. package/dist/hooks/useAttachment.d.ts +16 -8
  142. package/dist/hooks/useAttachment.d.ts.map +1 -1
  143. package/dist/hooks/useAttachment.js +35 -15
  144. package/dist/hooks/useFocusTrap.d.ts +15 -0
  145. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  146. package/dist/hooks/useFocusTrap.js +13 -2
  147. package/dist/hooks/usePager.d.ts +261 -0
  148. package/dist/hooks/usePager.d.ts.map +1 -0
  149. package/dist/hooks/usePager.js +1169 -0
  150. package/dist/motion/animator.d.ts.map +1 -1
  151. package/dist/motion/animator.js +140 -19
  152. package/dist/motion/paging.d.ts +69 -0
  153. package/dist/motion/paging.d.ts.map +1 -0
  154. package/dist/motion/paging.js +148 -0
  155. package/dist/motion/uikit/index.d.ts +8 -4
  156. package/dist/motion/uikit/index.d.ts.map +1 -1
  157. package/dist/motion/uikit/index.js +8 -4
  158. package/dist/motion/uikit/paging.d.ts +171 -0
  159. package/dist/motion/uikit/paging.d.ts.map +1 -0
  160. package/dist/motion/uikit/paging.js +192 -0
  161. package/dist/motion/uikit/progressBar.d.ts +79 -0
  162. package/dist/motion/uikit/progressBar.d.ts.map +1 -0
  163. package/dist/motion/uikit/progressBar.js +102 -0
  164. package/dist/motion/uikit/scroll.d.ts +0 -19
  165. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  166. package/dist/motion/uikit/scroll.js +2 -21
  167. package/dist/motion/uikit/slider.d.ts +97 -0
  168. package/dist/motion/uikit/slider.d.ts.map +1 -0
  169. package/dist/motion/uikit/slider.js +111 -0
  170. package/dist/motion/uikit/springs.d.ts +59 -30
  171. package/dist/motion/uikit/springs.d.ts.map +1 -1
  172. package/dist/motion/uikit/springs.js +64 -34
  173. package/dist/motion/uikit/switch.d.ts +166 -0
  174. package/dist/motion/uikit/switch.d.ts.map +1 -0
  175. package/dist/motion/uikit/switch.js +186 -0
  176. package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts +18 -0
  177. package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts.map +1 -0
  178. package/dist/privateAttributes/pressHoldsDrag.stylex.js +28 -0
  179. package/dist/utils/pressFeedback.d.ts.map +1 -1
  180. package/dist/utils/pressFeedback.js +5 -0
  181. package/dist/utils/selectionLock.d.ts +43 -0
  182. package/dist/utils/selectionLock.d.ts.map +1 -1
  183. package/dist/utils/selectionLock.js +394 -6
  184. package/package.json +5 -5
  185. package/src/AppShell/AppShell.doc.mjs +10 -0
  186. package/src/AppShell/AppShell.tsx +48 -2
  187. package/src/Avatar/Avatar.tsx +3 -1
  188. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  189. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  190. package/src/Button/Button.tsx +3 -2
  191. package/src/Chat/Chat.doc.mjs +1 -0
  192. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  193. package/src/Chat/ChatComposerInput.tsx +11 -0
  194. package/src/Chat/ChatMessage.tsx +2 -2
  195. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  196. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  197. package/src/DateInput/MonthScroller.tsx +63 -5
  198. package/src/DateInput/Wheel.tsx +174 -14
  199. package/src/DateRangeInput/DateRangeInput.doc.mjs +3 -2
  200. package/src/DateRangeInput/DateRangeInput.tsx +117 -71
  201. package/src/Dialog/Dialog.tsx +37 -1
  202. package/src/Drawer/Drawer.tsx +6 -0
  203. package/src/Drawer/useDrawerDialogPresence.ts +4 -0
  204. package/src/DropdownMenu/DropdownMenu.tsx +28 -6
  205. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  206. package/src/Field/PanelSearchInput.tsx +6 -0
  207. package/src/Field/textEntryTraits.ts +6 -6
  208. package/src/Layer/layerClickBoundary.ts +2 -2
  209. package/src/Layer/usePressPin.ts +516 -0
  210. package/src/Layer/useTouchTrigger.ts +56 -6
  211. package/src/Lightbox/Lightbox.tsx +2 -1
  212. package/src/Link/Link.tsx +3 -0
  213. package/src/Popover/usePopover.tsx +71 -9
  214. package/src/PowerSearch/PowerSearch.tsx +38 -31
  215. package/src/ProgressBar/ProgressBar.doc.mjs +7 -5
  216. package/src/ProgressBar/ProgressBar.tsx +147 -16
  217. package/src/ProgressBar/ProgressBarTouch.ts +109 -0
  218. package/src/SegmentedControl/SegmentedControlItem.tsx +2 -1
  219. package/src/Selector/Selector.tsx +29 -2
  220. package/src/Slider/Slider.doc.mjs +11 -7
  221. package/src/Slider/Slider.tsx +539 -132
  222. package/src/Slider/sliderTouchController.ts +1209 -0
  223. package/src/Slider/useSliderTouch.ts +78 -0
  224. package/src/Switch/Switch.doc.mjs +3 -3
  225. package/src/Switch/Switch.tsx +412 -86
  226. package/src/Switch/switchFill.ts +106 -0
  227. package/src/Switch/switchTouch.ts +194 -0
  228. package/src/Switch/useSwitchTouch.ts +1121 -0
  229. package/src/TabList/Tab.tsx +5 -3
  230. package/src/Tokenizer/Tokenizer.tsx +15 -5
  231. package/src/Tokenizer/TokenizerItemRemovableContext.ts +22 -0
  232. package/src/Tooltip/Tooltip.doc.mjs +21 -6
  233. package/src/Tooltip/Tooltip.tsx +42 -5
  234. package/src/Tooltip/index.ts +1 -0
  235. package/src/Tooltip/useTooltip.doc.mjs +4 -2
  236. package/src/Tooltip/useTooltip.tsx +84 -21
  237. package/src/Typeahead/BaseTypeahead.tsx +16 -8
  238. package/src/Typeahead/Typeahead.tsx +29 -15
  239. package/src/gestures/GestureArena.ts +45 -4
  240. package/src/gestures/GestureHost.ts +958 -15
  241. package/src/gestures/GestureRecognizer.ts +28 -0
  242. package/src/gestures/PanGestureRecognizer.ts +8 -0
  243. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  244. package/src/gestures/PressTracker.ts +10 -0
  245. package/src/gestures/README.md +82 -3
  246. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  247. package/src/gestures/nativeScroll.ts +49 -9
  248. package/src/gestures/recognizerInternals.ts +9 -2
  249. package/src/gestures/types.ts +18 -0
  250. package/src/gestures/usePressTracker.ts +2 -0
  251. package/src/gestures/wheelSegmenter.ts +143 -0
  252. package/src/hooks/useAttachment.ts +56 -14
  253. package/src/hooks/useFocusTrap.ts +26 -2
  254. package/src/hooks/usePager.ts +1446 -0
  255. package/src/motion/MEASUREMENTS.md +36 -27
  256. package/src/motion/README.md +7 -3
  257. package/src/motion/animator.ts +193 -25
  258. package/src/motion/paging.ts +161 -0
  259. package/src/motion/uikit/index.ts +7 -4
  260. package/src/motion/uikit/paging.ts +193 -0
  261. package/src/motion/uikit/progressBar.ts +102 -0
  262. package/src/motion/uikit/scroll.ts +2 -18
  263. package/src/motion/uikit/slider.ts +105 -0
  264. package/src/motion/uikit/springs.ts +53 -32
  265. package/src/motion/uikit/switch.ts +174 -0
  266. package/src/privateAttributes/pressHoldsDrag.stylex.ts +28 -0
  267. package/src/utils/pressFeedback.ts +5 -0
  268. package/src/utils/selectionLock.ts +501 -6
  269. package/dist/motion/selectPage.d.ts +0 -26
  270. package/dist/motion/selectPage.d.ts.map +0 -1
  271. package/dist/motion/selectPage.js +0 -55
  272. package/src/motion/selectPage.ts +0 -55
@@ -110,8 +110,9 @@
110
110
  * click; a click held for a recognizer that may still recognize is
111
111
  * replayed if they all fail. Keyboard and assistive-technology clicks
112
112
  * (`detail === 0`) always pass.
113
- * - A `contextmenu` while a recognizer owns a touch is cancelled, so a long
114
- * press never also opens the browser's menu.
113
+ * - A `contextmenu` while a recognizer owns a touch, or while a press
114
+ * consumer holds the menu closed (`holdsContextMenu`), is cancelled. So a
115
+ * long press never also opens the browser's menu.
115
116
  * - When the app loses focus (a window blur, a hidden page), every touch in
116
117
  * flight is cancelled, as in UIKit; a finger the browser keeps alive lifts
117
118
  * on nothing: its `touchend` is cancelled (`spec:AST-094` FR28).
@@ -140,9 +141,14 @@ import {
140
141
  import type {GestureRecognizer, SelectionLock} from './GestureRecognizer';
141
142
  import type {PressBounds} from './PressTracker';
142
143
  import type {PanGestureRecognizer} from './PanGestureRecognizer';
143
- import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll';
144
+ import {
145
+ canElementScroll,
146
+ createNativeScrollProbe,
147
+ parentAcrossShadow,
148
+ } from './nativeScroll';
144
149
  import {containsAlongFlatTree} from '../utils/flatTree';
145
150
  import {isIosWebKit} from '../utils/touchQueries';
151
+ import {type WheelSegment, WheelSegmenter} from './wheelSegmenter';
146
152
  import {
147
153
  gestureHostStyle,
148
154
  type GestureHostStyle,
@@ -158,6 +164,7 @@ import type {
158
164
  GestureEnvironment,
159
165
  GestureTouch,
160
166
  PointerKind,
167
+ ScrollType,
161
168
  TouchBeganInput,
162
169
  TouchConsumer,
163
170
  TouchInput,
@@ -171,7 +178,13 @@ import {
171
178
  } from '../utils/clickGuard';
172
179
  import {subscribeInput} from '../utils/inputStream';
173
180
  import {isPrimaryClick} from '../utils/primaryClick';
174
- import {locksSelectionFor} from '../utils/selectionLock';
181
+ import {
182
+ type DragSelectionReleaseOptions,
183
+ lockDragSelection,
184
+ locksSelectionFor,
185
+ selectionRangesOf,
186
+ startsOnSelectableText,
187
+ } from '../utils/selectionLock';
175
188
  import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer';
176
189
  import {gestureHosts} from './hostRegistry';
177
190
 
@@ -186,6 +199,8 @@ interface Registration {
186
199
  hasTouchEndListener: boolean;
187
200
  /** Whether the element carries the host's shared blocking touchmove handler. */
188
201
  hasTouchMoveListener: boolean;
202
+ /** Whether the element carries the host's wheel handler. */
203
+ hasWheelListener: boolean;
189
204
  /** Inline values the host replaced, to restore on detach. */
190
205
  readonly savedStyle: Map<string, string>;
191
206
  /**
@@ -305,6 +320,16 @@ function claimsWhileMoving(recognizer: GestureRecognizer): boolean {
305
320
  );
306
321
  }
307
322
 
323
+ /**
324
+ * Whether a recognizer takes a contact that comes with Touch Events and a
325
+ * long press's contextmenu: a finger's or a pen's. A scroll-only recognizer
326
+ * (`allowedPointerTypes: []`) and a mouse-only one take neither.
327
+ */
328
+ function takesTouchContacts(recognizer: GestureRecognizer): boolean {
329
+ const types = recognizer.allowedPointerTypes;
330
+ return types == null || types.has('touch') || types.has('pen');
331
+ }
332
+
308
333
  /**
309
334
  * A pan (or a pan subclass), told apart by its family rather than its class,
310
335
  * so a page that constructs no pan never ships PanGestureRecognizer.
@@ -381,6 +406,207 @@ function landedOn(
381
406
  return event.composedPath()[0] ?? retargeted;
382
407
  }
383
408
 
409
+ /**
410
+ * How long after its last event a pinch or a mouse wheel's notches end, ms.
411
+ * The web reports no wheel phases, so a quiet wheel is a lifted one.
412
+ */
413
+ const WHEEL_END_MS = 100;
414
+ /**
415
+ * How long after its last event a vertical trackpad scroll ends, ms (a
416
+ * sheet's pull), whoever keeps it. A slow drag's stream pauses longer than
417
+ * WHEEL_END_MS between events.
418
+ */
419
+ const WHEEL_FOLLOW_END_V_MS = 180;
420
+ /**
421
+ * The same horizontally, ms (a pager's or a row's swipe), whose slow
422
+ * swipes pause longer still.
423
+ */
424
+ const WHEEL_FOLLOW_END_MS = 220;
425
+ /** A line-mode wheel's line, px, as the platforms scroll it. */
426
+ const WHEEL_LINE_PX = 40;
427
+ /** The span a ctrl-wheel pinch's two contacts start at, px. */
428
+ const WHEEL_PINCH_SPAN = 100;
429
+
430
+ /** A wheel gesture: the contacts it drives in the arena, or none where the page keeps it. */
431
+ interface WheelGesture {
432
+ readonly kind: 'pan' | 'pinch';
433
+ /** A mouse wheel's notches, or a trackpad's continuous scroll. */
434
+ readonly scrollType: ScrollType;
435
+ readonly ids: ReadonlyArray<number>;
436
+ /** A pan's contact, or a pinch's center. */
437
+ x: number;
438
+ y: number;
439
+ /** A pinch's span between its two contacts. */
440
+ span: number;
441
+ /** The latest wheel's time. */
442
+ last: number;
443
+ /**
444
+ * How long after its last wheel it ends, ms. A trackpad pan stream waits
445
+ * out its axis's pause bound, whoever keeps it. A pinch or a mouse wheel's
446
+ * notches wait 100 ms.
447
+ */
448
+ readonly endMs: number;
449
+ timer: unknown;
450
+ /** The recognizers that took it; none where the page keeps it. */
451
+ readonly takers: ReadonlyArray<GestureRecognizer>;
452
+ /** A trackpad scroll a pan follows, until its fingers lift. */
453
+ follow?: WheelFollow;
454
+ }
455
+
456
+ /** A trackpad scroll a pan follows: where its fingers lift, and what waits until then. */
457
+ interface WheelFollow {
458
+ /** The fingers lifted: the momentum after them never moves the pan. */
459
+ lifted: boolean;
460
+ /**
461
+ * Shrinking deltas held until the splitter decides. They are the fingers'
462
+ * when larger ones follow, and the start of momentum when it reads the
463
+ * lift.
464
+ */
465
+ heldX: number;
466
+ heldY: number;
467
+ /** The time of the fingers' last move the pan took. */
468
+ lastMove: number;
469
+ /** The fingers' travel as of that move: the contact without any held delta. */
470
+ trueX: number;
471
+ trueY: number;
472
+ /** Where the pan was shown at that move, which the undecided stretch moves on from. */
473
+ baseX: number;
474
+ baseY: number;
475
+ /** The fingers' speed at that move, content px per ms. */
476
+ speedX: number;
477
+ speedY: number;
478
+ }
479
+
480
+ /**
481
+ * How long after its last wheel a wheel gesture ends, ms. A trackpad's
482
+ * two-finger scroll waits out a slow drag's pauses for its axis, whoever
483
+ * keeps the stream. A pinch and a mouse wheel's notches end 100 ms after
484
+ * their last wheel.
485
+ */
486
+ function wheelEndMs(
487
+ kind: 'pan' | 'pinch',
488
+ scrollType: ScrollType,
489
+ isHorizontal: boolean,
490
+ ): number {
491
+ if (kind === 'pinch' || scrollType !== 'continuous') {
492
+ return WHEEL_END_MS;
493
+ }
494
+ return isHorizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS;
495
+ }
496
+
497
+ /**
498
+ * A provisional lead `debt` after a move of `moved`. The debt is what a
499
+ * followed pan shows past the fingers' travel, or short of it. Half the move
500
+ * is absorbed, so the pan keeps moving the way the fingers do.
501
+ */
502
+ function absorbedDebt(debt: number, moved: number): number {
503
+ return Math.sign(debt) * Math.max(0, Math.abs(debt) - Math.abs(moved) / 2);
504
+ }
505
+
506
+ /**
507
+ * `target` where it is ahead of `shown` in the direction of `speed`, else
508
+ * `shown`: a reconcile that never steps back.
509
+ */
510
+ function ahead(shown: number, target: number, speed: number): number {
511
+ return speed === 0 || (target - shown) * Math.sign(speed) > 0
512
+ ? target
513
+ : shown;
514
+ }
515
+
516
+ /**
517
+ * A wheel's deltas in CSS px: a line is `WHEEL_LINE_PX`, a page the
518
+ * viewport.
519
+ */
520
+ function wheelPixels(
521
+ wheel: WheelEvent,
522
+ view: Window | null,
523
+ ): {dx: number; dy: number} {
524
+ if (wheel.deltaMode === 1) {
525
+ return {dx: wheel.deltaX * WHEEL_LINE_PX, dy: wheel.deltaY * WHEEL_LINE_PX};
526
+ }
527
+ if (wheel.deltaMode === 2) {
528
+ return {
529
+ dx: wheel.deltaX * (view?.innerWidth ?? 0),
530
+ dy: wheel.deltaY * (view?.innerHeight ?? 0),
531
+ };
532
+ }
533
+ return {dx: wheel.deltaX, dy: wheel.deltaY};
534
+ }
535
+
536
+ /**
537
+ * The web's best reading of what scrolled: a mouse wheel's notches
538
+ * (`discrete`) or a trackpad's scroll (`continuous`). Input it cannot tell
539
+ * apart counts as a mouse wheel's, which a pan that takes only a trackpad's
540
+ * scroll leaves to the page. These read as a mouse wheel's:
541
+ * - a line- or page-mode delta;
542
+ * - a scroll with Shift held (Shift+wheel);
543
+ * - a pixel delta a hair off a whole pixel (a mouse wheel on Windows);
544
+ * - a scroll with no legacy `wheelDelta` (an engine that sends none);
545
+ * - a delta that is not a third of its legacy `wheelDelta`;
546
+ * - a 120 tick over a 40 px delta.
547
+ * A trackpad's precise deltas, a third of their legacy `wheelDelta` in
548
+ * Chromium and WebKit, read as a trackpad's.
549
+ */
550
+ function scrollTypeOf(wheel: WheelEvent): ScrollType {
551
+ if (wheel.deltaMode !== 0 || wheel.shiftKey) {
552
+ return 'discrete';
553
+ }
554
+ const remainder = (value: number) => {
555
+ const fraction = Math.abs(value - Math.round(value));
556
+ return fraction > 0 && fraction < 0.01;
557
+ };
558
+ if (remainder(wheel.deltaX) || remainder(wheel.deltaY)) {
559
+ return 'discrete';
560
+ }
561
+ const legacy = wheel as WheelEvent & {
562
+ wheelDeltaX?: number;
563
+ wheelDeltaY?: number;
564
+ };
565
+ if (
566
+ typeof legacy.wheelDeltaY !== 'number' ||
567
+ typeof legacy.wheelDeltaX !== 'number'
568
+ ) {
569
+ return 'discrete';
570
+ }
571
+ const precise = (delta: number, legacyDelta: number) =>
572
+ delta === 0
573
+ ? legacyDelta === 0
574
+ : Math.abs(Math.abs(legacyDelta) - 3 * Math.abs(delta)) <=
575
+ Math.max(1.5, 0.05 * Math.abs(delta) * 3);
576
+ if (
577
+ !precise(wheel.deltaY, legacy.wheelDeltaY) ||
578
+ !precise(wheel.deltaX, legacy.wheelDeltaX)
579
+ ) {
580
+ return 'discrete';
581
+ }
582
+ const tick = (delta: number, legacyDelta: number) =>
583
+ legacyDelta !== 0 && legacyDelta % 120 === 0 && delta % 40 === 0;
584
+ return tick(wheel.deltaY, legacy.wheelDeltaY) ||
585
+ tick(wheel.deltaX, legacy.wheelDeltaX)
586
+ ? 'discrete'
587
+ : 'continuous';
588
+ }
589
+
590
+ /** Read without the pan class, which a page with no pan never ships. */
591
+ function isScrollEdgePan(recognizer: GestureRecognizer): boolean {
592
+ return (recognizer as {scrollEdge?: boolean}).scrollEdge === true;
593
+ }
594
+
595
+ /**
596
+ * Whether a pan takes a contact moving by (cx, cy), by its own direction
597
+ * rule, `axisRatio` included. A move its rule leaves undecided stays the
598
+ * page's.
599
+ */
600
+ function panTakesMove(
601
+ recognizer: GestureRecognizer,
602
+ cx: number,
603
+ cy: number,
604
+ ): boolean {
605
+ return (
606
+ (internals(recognizer).classify?.({x: cx, y: cy}) ?? 'begin') === 'begin'
607
+ );
608
+ }
609
+
384
610
  /**
385
611
  * An event's time on the arena's clock (`performance.now()`). Some engines
386
612
  * and DOM emulators stamp events in epoch milliseconds instead.
@@ -416,7 +642,35 @@ export class GestureHost {
416
642
  /** Cancels the scheduled next-frame read (readCallerTouchActionInNextFrame). */
417
643
  private cancelCallerRead: (() => void) | null = null;
418
644
  /** The selection lock each contact holds, by host contact id. */
419
- private readonly lockedBy = new Map<number, () => void>();
645
+ private readonly lockedBy = new Map<
646
+ number,
647
+ (options?: DragSelectionReleaseOptions) => void
648
+ >();
649
+ /**
650
+ * The page's selection when each mouse or pen press began: a drag a
651
+ * recognizer claims keeps it.
652
+ */
653
+ private readonly pressSelections = new Map<
654
+ number,
655
+ ReadonlyArray<AbstractRange>
656
+ >();
657
+ /**
658
+ * Mouse and pen contacts that began on selectable text. Once one moves, the
659
+ * browser keeps the drag for a selection, and no recognizer claims it.
660
+ */
661
+ private readonly selectingContacts = new Set<number>();
662
+ /**
663
+ * Drag selection holds until their last task ends, a task after their lift
664
+ * (lockDragSelection); the last detach ends them at once.
665
+ */
666
+ private readonly dragHolds = new Set<
667
+ (options?: DragSelectionReleaseOptions) => void
668
+ >();
669
+ /** Each claimed contact's drag hold, until its lift or a give-back. */
670
+ private readonly claimHolds = new Map<
671
+ number,
672
+ (options?: DragSelectionReleaseOptions) => void
673
+ >();
420
674
  private readonly presentedHitTesting: 'auto' | 'always' | 'never';
421
675
  /**
422
676
  * Contacts sent to what is drawn instead of what the engine hit, by host
@@ -530,6 +784,16 @@ export class GestureHost {
530
784
  */
531
785
  private fingerTargets: ReadonlyArray<Node> = [];
532
786
  private nextId = 1;
787
+ /** The wheel gesture in progress. */
788
+ private wheel: WheelGesture | null = null;
789
+ /** The wheel latch's input-stream subscription, while a gesture is latched. */
790
+ private stopWheelLatch: (() => void) | null = null;
791
+ /** Splits the wheel stream into gestures and reads a trackpad's lift. */
792
+ private readonly segmenter = new WheelSegmenter();
793
+ /** The latest wheel's segment. */
794
+ private wheelSegment: WheelSegment | null = null;
795
+ /** Wheels already taken: each element on the path with a listener hears one. */
796
+ private readonly wheelsSeen = new WeakSet<Event>();
533
797
  private isInstalled = false;
534
798
  /**
535
799
  * Whether the primary pointer is coarse (`spec:AST-094` FR19), read when
@@ -587,6 +851,11 @@ export class GestureHost {
587
851
  // tap) ends here: an armed press stops listening (FR24).
588
852
  onIdle: () => this.releaseArmedPress(),
589
853
  onTransition: options.onTransition,
854
+ onClaim: (ids, claimer) => this.lockClaimedDrags(ids, claimer),
855
+ onRelease: ids => this.releaseClaimedDrags(ids),
856
+ refusesClaim: touch =>
857
+ this.selectingContacts.has(touch.id) &&
858
+ (touch.x !== touch.startX || touch.y !== touch.startY),
590
859
  });
591
860
  const view = doc.defaultView;
592
861
  // A test may pass its own read; otherwise the one coarse-pointer gate,
@@ -648,6 +917,16 @@ export class GestureHost {
648
917
  if (recognizer.state !== 'possible' || recognizer.isInvolved()) {
649
918
  recognizer.cancel();
650
919
  }
920
+ const wheel = this.wheel;
921
+ if (
922
+ wheel != null &&
923
+ wheel.takers.includes(recognizer) &&
924
+ !this.wheelHasTaker(wheel)
925
+ ) {
926
+ // At its last wheel: a later stamp would move the arena's clock past
927
+ // the next input's own time.
928
+ this.endWheel(wheel.last);
929
+ }
651
930
  this.refresh(element, registration);
652
931
  // Even when the element keeps other attachments: one left disabled
653
932
  // keeps no listener or observer (FR24).
@@ -1055,6 +1334,7 @@ export class GestureHost {
1055
1334
  consumer: null,
1056
1335
  hasTouchEndListener: false,
1057
1336
  hasTouchMoveListener: false,
1337
+ hasWheelListener: false,
1058
1338
  savedStyle: new Map(),
1059
1339
  authorTouchAction: null,
1060
1340
  manageStyle: true,
@@ -1135,6 +1415,26 @@ export class GestureHost {
1135
1415
  element.removeEventListener('touchmove', this.onBlockingTouchMove);
1136
1416
  registration.hasTouchMoveListener = false;
1137
1417
  }
1418
+ // Wheel and trackpad input, only where an enabled recognizer takes it. A
1419
+ // non-passive wheel listener makes the browser's scrolling over the
1420
+ // element wait for the main thread.
1421
+ const needsWheel = registration.recognizers.some(
1422
+ recognizer =>
1423
+ recognizer.isEnabled &&
1424
+ // A watcher never takes the wheel: the page keeps it.
1425
+ recognizer.claimsTouches &&
1426
+ recognizer.allowedScrollTypes.size > 0 &&
1427
+ // A wheel drives a pan or a pinch only.
1428
+ (internals(recognizer).family === 'pan' ||
1429
+ internals(recognizer).family === 'pinch'),
1430
+ );
1431
+ if (needsWheel && !registration.hasWheelListener) {
1432
+ element.addEventListener('wheel', this.onWheel, {passive: false});
1433
+ registration.hasWheelListener = true;
1434
+ } else if (!needsWheel && registration.hasWheelListener) {
1435
+ element.removeEventListener('wheel', this.onWheel);
1436
+ registration.hasWheelListener = false;
1437
+ }
1138
1438
  }
1139
1439
 
1140
1440
  private applyStyle(element: HTMLElement, registration: Registration): void {
@@ -1290,6 +1590,9 @@ export class GestureHost {
1290
1590
  if (registration.hasTouchMoveListener) {
1291
1591
  element.removeEventListener('touchmove', this.onBlockingTouchMove);
1292
1592
  }
1593
+ if (registration.hasWheelListener) {
1594
+ element.removeEventListener('wheel', this.onWheel);
1595
+ }
1293
1596
  this.registry.delete(element);
1294
1597
  this.uninstallIfIdle();
1295
1598
  }
@@ -1394,7 +1697,13 @@ export class GestureHost {
1394
1697
  for (const [element, registration] of this.registry) {
1395
1698
  const consumer = registration.consumer;
1396
1699
  if (
1397
- registration.recognizers.some(recognizer => recognizer.isEnabled) ||
1700
+ registration.recognizers.some(
1701
+ recognizer =>
1702
+ recognizer.isEnabled &&
1703
+ // One that takes only scroll input has no press to arm.
1704
+ (recognizer.allowedPointerTypes == null ||
1705
+ recognizer.allowedPointerTypes.size > 0),
1706
+ ) ||
1398
1707
  (consumer != null && consumer.isEnabled?.() !== false)
1399
1708
  ) {
1400
1709
  wanted.add(element);
@@ -1510,6 +1819,13 @@ export class GestureHost {
1510
1819
  }
1511
1820
  this.ownScrollFrames.clear();
1512
1821
  this.ownedScrolls.clear();
1822
+ // A drag's hold whose press lifted in this task ends now, not a task
1823
+ // later: nothing is attached to meet its mouseup or click.
1824
+ for (const release of [...this.dragHolds]) {
1825
+ release({isCancel: true});
1826
+ }
1827
+ this.pressSelections.clear();
1828
+ this.selectingContacts.clear();
1513
1829
  });
1514
1830
  }
1515
1831
 
@@ -1545,8 +1861,9 @@ export class GestureHost {
1545
1861
 
1546
1862
  /**
1547
1863
  * Whether an enabled participant could claim a touch or cancel a lift: a
1548
- * consumer, or a recognizer that claims touches. With only watchers
1549
- * attached, the host's listeners are all passive (`spec:AST-094` FR20).
1864
+ * consumer, or a recognizer that claims touches and takes a finger or a
1865
+ * pen. With only watchers, or only scroll-only recognizers
1866
+ * (`allowedPointerTypes: []`), the host's listeners are all passive.
1550
1867
  */
1551
1868
  private hasClaimer(): boolean {
1552
1869
  for (const registration of this.registry.values()) {
@@ -1554,7 +1871,10 @@ export class GestureHost {
1554
1871
  if (
1555
1872
  (consumer != null && consumer.isEnabled?.() !== false) ||
1556
1873
  registration.recognizers.some(
1557
- recognizer => recognizer.isEnabled && recognizer.claimsTouches,
1874
+ recognizer =>
1875
+ recognizer.isEnabled &&
1876
+ recognizer.claimsTouches &&
1877
+ takesTouchContacts(recognizer),
1558
1878
  )
1559
1879
  ) {
1560
1880
  return true;
@@ -1827,10 +2147,617 @@ export class GestureHost {
1827
2147
  }
1828
2148
  }
1829
2149
 
1830
- /** Undo `lockSelection` for a contact that ended; idempotent. */
1831
- private unlockSelection(id: number): void {
1832
- this.lockedBy.get(id)?.();
2150
+ /**
2151
+ * Wheel and trackpad input. A wheel gesture drives the recognizers that
2152
+ * allow its scroll type, as a contact that moves with the content. A pan
2153
+ * takes it along its own axis, once no scroller between the pointer and
2154
+ * the pan's element can scroll that way. The gesture's first wheel decides
2155
+ * this. A pinch takes a ctrl-wheel, as a trackpad pinches, as two contacts
2156
+ * whose span follows exp(-deltaY / 100). A wheel the page keeps is never
2157
+ * prevented, and a wheel a recognizer takes always is. A gesture ends its
2158
+ * end window after its last wheel (wheelEndMs), at that wheel's time, so
2159
+ * its release keeps its velocity. A gesture a recognizer took is latched
2160
+ * to it, as a browser latches a scroller. While the gesture lasts, the
2161
+ * shared input stream hands this every wheel first, wherever it lands. So
2162
+ * a pan whose element moves from under a still pointer keeps the gesture.
2163
+ * The page listener exists only during a gesture.
2164
+ */
2165
+ private readonly onWheel = (event: Event): void => {
2166
+ const wheel = event as WheelEvent;
2167
+ if (this.wheelsSeen.has(wheel)) {
2168
+ return;
2169
+ }
2170
+ this.wheelsSeen.add(wheel);
2171
+ const time = eventTime(wheel);
2172
+ const kind = wheel.ctrlKey ? 'pinch' : 'pan';
2173
+ let gesture = this.wheel;
2174
+ if (
2175
+ gesture != null &&
2176
+ (gesture.kind !== kind || time - gesture.last >= gesture.endMs)
2177
+ ) {
2178
+ this.endWheel(time);
2179
+ gesture = null;
2180
+ }
2181
+ if (
2182
+ gesture != null &&
2183
+ gesture.ids.length > 0 &&
2184
+ gesture.follow?.lifted !== true &&
2185
+ !this.wheelHasTaker(gesture)
2186
+ ) {
2187
+ // Every recognizer that took it has left (detached, disabled or
2188
+ // cancelled), so the rest of the gesture is the page's. A followed
2189
+ // pan's own lift does not count. Its momentum stays latched and
2190
+ // prevented until the splitter's next gesture or the end timer, and
2191
+ // moves nothing. The release is stamped at its last wheel.
2192
+ this.endWheel(gesture.last);
2193
+ const left = wheelPixels(wheel, this.doc.defaultView);
2194
+ gesture = {
2195
+ kind,
2196
+ scrollType: scrollTypeOf(wheel),
2197
+ ids: [],
2198
+ x: wheel.clientX,
2199
+ y: wheel.clientY,
2200
+ span: WHEEL_PINCH_SPAN,
2201
+ last: time,
2202
+ endMs: wheelEndMs(
2203
+ kind,
2204
+ scrollTypeOf(wheel),
2205
+ Math.abs(left.dx) > Math.abs(left.dy),
2206
+ ),
2207
+ timer: null,
2208
+ takers: [],
2209
+ };
2210
+ this.wheel = gesture;
2211
+ }
2212
+ const {dx, dy} = wheelPixels(wheel, this.doc.defaultView);
2213
+ const horizontal = Math.abs(dx) > Math.abs(dy);
2214
+ // The splitter reads a pan's stream only. A ctrl-wheel pinch has no
2215
+ // fingers' phase or momentum to tell apart, and ends 100 ms after its
2216
+ // last wheel.
2217
+ let seg: WheelSegment | null = null;
2218
+ if (kind === 'pan') {
2219
+ seg = this.segmenter.push(
2220
+ time,
2221
+ horizontal ? dx : dy,
2222
+ horizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS,
2223
+ );
2224
+ } else {
2225
+ this.segmenter.end();
2226
+ }
2227
+ this.wheelSegment = seg;
2228
+ if (seg?.boundary != null) {
2229
+ if (gesture != null) {
2230
+ this.endWheel(time);
2231
+ gesture = null;
2232
+ }
2233
+ }
2234
+ if (gesture == null) {
2235
+ gesture = this.beginWheel(wheel, kind, dx, dy, time);
2236
+ this.wheel = gesture;
2237
+ if (gesture.ids.length > 0) {
2238
+ this.latchWheel();
2239
+ }
2240
+ }
2241
+ gesture.last = time;
2242
+ if (gesture.timer != null) {
2243
+ this.clock.clearTimeout(gesture.timer);
2244
+ }
2245
+ // A trackpad scroll waits out a slow drag's pauses, whoever
2246
+ // keeps it; a pinch or a mouse wheel's notches 100 ms.
2247
+ gesture.timer = this.clock.setTimeout(() => {
2248
+ this.segmenter.end();
2249
+ this.endWheel();
2250
+ }, gesture.endMs);
2251
+ if (gesture.ids.length === 0) {
2252
+ return;
2253
+ }
2254
+ wheel.preventDefault();
2255
+ if (gesture.kind === 'pan' && gesture.follow) {
2256
+ const f = gesture.follow;
2257
+ if (f.lifted) {
2258
+ return;
2259
+ }
2260
+ if (this.wheelSegment?.liftNow === true) {
2261
+ // Momentum began: the fingers lifted before the held deltas. The pan
2262
+ // keeps the travel it showed through the undecided stretch. It lets
2263
+ // go here, still moving at the fingers' speed, so its release carries
2264
+ // on from it. The rest of the momentum never moves the pan.
2265
+ this.followProvisionally(gesture, f, time);
2266
+ f.lifted = true;
2267
+ this.arena.ended([
2268
+ {id: gesture.ids[0], x: gesture.x, y: gesture.y, time},
2269
+ ]);
2270
+ return;
2271
+ }
2272
+ if (this.wheelSegment?.decaying === true) {
2273
+ // Undecided: the real deltas wait for the splitter while the
2274
+ // fingers' last speed carries the pan on.
2275
+ f.heldX += dx;
2276
+ f.heldY += dy;
2277
+ this.followProvisionally(gesture, f, time);
2278
+ return;
2279
+ }
2280
+ // The fingers' own delta; the held ones were theirs too. Their moves
2281
+ // absorb what the pan shows ahead of their travel, or behind it, half a
2282
+ // move at a time. So the pan never steps back, and ends on their travel.
2283
+ const beforeX = f.trueX - f.heldX;
2284
+ const beforeY = f.trueY - f.heldY;
2285
+ const debtX = absorbedDebt(gesture.x - beforeX, dx);
2286
+ const debtY = absorbedDebt(gesture.y - beforeY, dy);
2287
+ const trueX = beforeX - dx;
2288
+ const trueY = beforeY - dy;
2289
+ // A wheel the splitter reads as one stall's merge sets no speed. Its
2290
+ // speed is one the fingers never had.
2291
+ const elapsed = time - f.lastMove;
2292
+ if (elapsed > 0 && this.wheelSegment?.bunched !== true) {
2293
+ f.speedX = (trueX - f.trueX) / elapsed;
2294
+ f.speedY = (trueY - f.trueY) / elapsed;
2295
+ }
2296
+ f.trueX = trueX;
2297
+ f.trueY = trueY;
2298
+ f.heldX = 0;
2299
+ f.heldY = 0;
2300
+ f.lastMove = time;
2301
+ gesture.x = trueX + debtX;
2302
+ gesture.y = trueY + debtY;
2303
+ f.baseX = gesture.x;
2304
+ f.baseY = gesture.y;
2305
+ this.arena.moved([
2306
+ {id: gesture.ids[0], x: gesture.x, y: gesture.y, time},
2307
+ ]);
2308
+ return;
2309
+ }
2310
+ if (gesture.kind === 'pan') {
2311
+ gesture.x -= dx;
2312
+ gesture.y -= dy;
2313
+ this.arena.moved([
2314
+ {id: gesture.ids[0], x: gesture.x, y: gesture.y, time},
2315
+ ]);
2316
+ } else {
2317
+ gesture.span *= Math.exp(-dy / 100);
2318
+ this.arena.moved(this.pinchContacts(gesture, time));
2319
+ }
2320
+ };
2321
+
2322
+ /**
2323
+ * Move a followed pan through the splitter's undecided stretch. It moves on
2324
+ * from where it was shown at the fingers' last move, at their last speed.
2325
+ * It never passes what that speed projects for the time since.
2326
+ */
2327
+ private followProvisionally(
2328
+ gesture: WheelGesture,
2329
+ f: WheelFollow,
2330
+ time: number,
2331
+ ): void {
2332
+ const elapsed = Math.max(0, time - f.lastMove);
2333
+ const x = f.baseX + f.speedX * elapsed;
2334
+ const y = f.baseY + f.speedY * elapsed;
2335
+ if (x === gesture.x && y === gesture.y) {
2336
+ return;
2337
+ }
2338
+ gesture.x = x;
2339
+ gesture.y = y;
2340
+ this.arena.moved([{id: gesture.ids[0], x, y, time}]);
2341
+ }
2342
+
2343
+ /** Start a wheel gesture: who takes it, decided once, or nobody. */
2344
+ private beginWheel(
2345
+ wheel: WheelEvent,
2346
+ kind: 'pan' | 'pinch',
2347
+ dx: number,
2348
+ dy: number,
2349
+ time: number,
2350
+ ): WheelGesture {
2351
+ const type = scrollTypeOf(wheel);
2352
+ const isHorizontal = Math.abs(dx) > Math.abs(dy);
2353
+ const kept: WheelGesture = {
2354
+ kind,
2355
+ scrollType: type,
2356
+ ids: [],
2357
+ x: wheel.clientX,
2358
+ y: wheel.clientY,
2359
+ span: WHEEL_PINCH_SPAN,
2360
+ last: time,
2361
+ endMs: wheelEndMs(kind, type, isHorizontal),
2362
+ timer: null,
2363
+ takers: [],
2364
+ };
2365
+ // A contact already down keeps its gesture; the wheel stays the page's.
2366
+ // So does a first wheel another handler already prevented, or one that
2367
+ // cannot be cancelled: no recognizer could take it from the page.
2368
+ if (
2369
+ this.arena.touches.length > 0 ||
2370
+ (dx === 0 && dy === 0) ||
2371
+ wheel.defaultPrevented ||
2372
+ !wheel.cancelable
2373
+ ) {
2374
+ return kept;
2375
+ }
2376
+ // Every scroller under the pointer, the page included, as a finger's
2377
+ // probe reads them, without touch-action, which a wheel ignores.
2378
+ // What the wheel landed on: at a shadow host's listener the event's
2379
+ // target is the host, and a scroller inside its tree must count.
2380
+ const target = landedOn(wheel);
2381
+ const scroll = createNativeScrollProbe(target, {
2382
+ readsTouchAction: false,
2383
+ });
2384
+ const takers: GestureRecognizer[] = [];
2385
+ for (
2386
+ let node: Node | null = target instanceof Node ? target : null;
2387
+ node != null;
2388
+ node = parentAcrossShadow(node)
2389
+ ) {
2390
+ if (!(node instanceof Element)) {
2391
+ continue;
2392
+ }
2393
+ for (const recognizer of this.registry.get(node)?.recognizers ?? []) {
2394
+ // A pan follows a trackpad, never a mouse wheel's notches, which
2395
+ // scroll what is under the pointer.
2396
+ if (
2397
+ !recognizer.isEnabled ||
2398
+ !recognizer.claimsTouches ||
2399
+ !recognizer.allowedScrollTypes.has(type) ||
2400
+ internals(recognizer).family !== kind ||
2401
+ (kind === 'pan' && type !== 'continuous')
2402
+ ) {
2403
+ continue;
2404
+ }
2405
+ if (
2406
+ kind === 'pan' &&
2407
+ (!panTakesMove(recognizer, -dx, -dy) ||
2408
+ this.scrollerTakes(target, node, dx, dy, isHorizontal) ||
2409
+ // A scroll-edge pan only past every scroller's edge.
2410
+ (isScrollEdgePan(recognizer) && scroll.canScroll(-dx, -dy)))
2411
+ ) {
2412
+ continue;
2413
+ }
2414
+ takers.push(recognizer);
2415
+ }
2416
+ }
2417
+ if (takers.length === 0) {
2418
+ return kept;
2419
+ }
2420
+ const base = {
2421
+ pointerType: 'mouse' as const,
2422
+ target,
2423
+ scroll,
2424
+ scrollType: type,
2425
+ recognizers: takers,
2426
+ consumer: null,
2427
+ };
2428
+ const ids =
2429
+ kind === 'pan' ? [this.nextId++] : [this.nextId++, this.nextId++];
2430
+ const gesture: WheelGesture = {...kept, ids, takers};
2431
+ if (kind === 'pan' && type === 'continuous') {
2432
+ gesture.follow = {
2433
+ lifted: false,
2434
+ heldX: 0,
2435
+ heldY: 0,
2436
+ lastMove: time,
2437
+ trueX: kept.x,
2438
+ trueY: kept.y,
2439
+ baseX: kept.x,
2440
+ baseY: kept.y,
2441
+ speedX: 0,
2442
+ speedY: 0,
2443
+ };
2444
+ }
2445
+ this.arena.began(
2446
+ kind === 'pan'
2447
+ ? [{...base, id: ids[0], x: wheel.clientX, y: wheel.clientY, time}]
2448
+ : this.pinchContacts(gesture, time).map(input => ({
2449
+ ...base,
2450
+ ...input,
2451
+ })),
2452
+ );
2453
+ // Every recognizer it was offered to may decline it (its own
2454
+ // shouldReceiveTouch). Then nobody took the wheel, and it scrolls the
2455
+ // page from its first step, as with no gesture system.
2456
+ if (ids.every(id => this.arena.participantsOf(id).length === 0)) {
2457
+ this.arena.cancelled(ids, time);
2458
+ return kept;
2459
+ }
2460
+ return gesture;
2461
+ }
2462
+
2463
+ /**
2464
+ * Whether a scroller between `target` and `boundary` (both included)
2465
+ * scrolls this wheel now, along the wheel's major axis. It uses the edge
2466
+ * test a finger's probe uses (canElementScroll).
2467
+ */
2468
+ private scrollerTakes(
2469
+ target: unknown,
2470
+ boundary: Element,
2471
+ dx: number,
2472
+ dy: number,
2473
+ isHorizontal: boolean,
2474
+ ): boolean {
2475
+ for (
2476
+ let node: Node | null = target instanceof Node ? target : null;
2477
+ node != null;
2478
+ node = parentAcrossShadow(node)
2479
+ ) {
2480
+ if (
2481
+ node instanceof Element &&
2482
+ canElementScroll(node, isHorizontal ? -dx : 0, isHorizontal ? 0 : -dy)
2483
+ ) {
2484
+ return true;
2485
+ }
2486
+ if (node === boundary) {
2487
+ break;
2488
+ }
2489
+ }
2490
+ return false;
2491
+ }
2492
+
2493
+ /** A ctrl-wheel pinch's two contacts, `span` apart around its center. */
2494
+ private pinchContacts(
2495
+ gesture: WheelGesture,
2496
+ time: number,
2497
+ ): {id: number; x: number; y: number; time: number}[] {
2498
+ return [
2499
+ {id: gesture.ids[0], x: gesture.x - gesture.span / 2, y: gesture.y, time},
2500
+ {id: gesture.ids[1], x: gesture.x + gesture.span / 2, y: gesture.y, time},
2501
+ ];
2502
+ }
2503
+
2504
+ /** Whether a recognizer that took this wheel gesture still takes part in it. */
2505
+ private wheelHasTaker(gesture: WheelGesture): boolean {
2506
+ // A cancelled or failed recognizer keeps that state until its contacts
2507
+ // end, which a wheel gesture's do only here.
2508
+ return gesture.takers.some(recognizer => {
2509
+ const {state} = recognizer;
2510
+ return (
2511
+ recognizer.isEnabled &&
2512
+ (state === 'began' ||
2513
+ state === 'changed' ||
2514
+ (state === 'possible' && recognizer.isInvolved()))
2515
+ );
2516
+ });
2517
+ }
2518
+
2519
+ /**
2520
+ * Route every wheel on the page to the live wheel gesture, from its claim.
2521
+ * The route is the shared input stream's capture listener on the window,
2522
+ * which runs before any document listener. It is non-passive only while it
2523
+ * is subscribed.
2524
+ */
2525
+ private latchWheel(): void {
2526
+ const view = this.doc.defaultView;
2527
+ if (this.stopWheelLatch == null && view != null) {
2528
+ const cancelWheel = () => {
2529
+ const ids = this.dropDrivenWheel();
2530
+ if (ids.length > 0) {
2531
+ this.arena.cancelled(ids, this.arena.now());
2532
+ }
2533
+ };
2534
+ const stops = [
2535
+ subscribeInput(view, 'wheel', this.onWheel, {cancels: true}),
2536
+ // A page that loses focus or hides ends a trackpad pan, as it ends
2537
+ // every touch. An element's blur reaches the window's capture phase
2538
+ // too and means nothing here.
2539
+ subscribeInput(view, 'blur', event => {
2540
+ if (!(event.target instanceof view.Node)) {
2541
+ cancelWheel();
2542
+ }
2543
+ }),
2544
+ subscribeInput(view, 'visibilitychange', () => {
2545
+ if (this.doc.visibilityState === 'hidden') {
2546
+ cancelWheel();
2547
+ }
2548
+ }),
2549
+ ];
2550
+ this.stopWheelLatch = () => {
2551
+ for (const stop of stops) {
2552
+ stop();
2553
+ }
2554
+ };
2555
+ }
2556
+ }
2557
+
2558
+ /**
2559
+ * Hold the page's selection for a mouse or pen contact that a recognizer
2560
+ * claims. Examples are a pan's drag and a press a group claims at its own
2561
+ * pointerdown. While the contact is down, the claiming recognizers'
2562
+ * elements refuse a new selection. The selection the page had at the press
2563
+ * is put back over anything that replaced it. That includes the press's own
2564
+ * collapse and a selection the drag made before its claim. The claiming
2565
+ * recognizer's element holds from its claim, in any state: a waiting
2566
+ * recognizer claims while possible, and a swipe claims as ended. The
2567
+ * participants that already recognize hold too. A claim given back to the
2568
+ * browser lets its hold go (releaseClaimedDrags). A drag that starts on
2569
+ * selectable text never gets here, because no recognizer claims it
2570
+ * (`selectingContacts`). A tap claims only at its lift, so a click keeps
2571
+ * the selection it made. A finger keeps the touch-down lock alone.
2572
+ */
2573
+ private lockClaimedDrags(
2574
+ ids: ReadonlyArray<number>,
2575
+ claimer: GestureRecognizer,
2576
+ ): void {
2577
+ for (const id of ids) {
2578
+ if (this.claimHolds.has(id)) {
2579
+ continue;
2580
+ }
2581
+ const touch = this.arena.touches.find(each => each.id === id);
2582
+ // A claim made at the contact's own lift (a tap's) holds nothing: the
2583
+ // drag is over, and a click keeps the selection it made. A wheel's
2584
+ // contact keeps the page's selection, as a scroll does.
2585
+ if (
2586
+ touch == null ||
2587
+ touch.pointerType === 'touch' ||
2588
+ touch.scrollType != null ||
2589
+ touch.phase === 'ended' ||
2590
+ touch.phase === 'cancelled'
2591
+ ) {
2592
+ continue;
2593
+ }
2594
+ const elements = new Set<Element>();
2595
+ for (const recognizer of this.arena.participantsOf(id)) {
2596
+ if (
2597
+ (recognizer.state === 'began' || recognizer.state === 'changed') &&
2598
+ (recognizer.view as Node | null)?.nodeType === 1
2599
+ ) {
2600
+ elements.add(recognizer.view as Element);
2601
+ }
2602
+ }
2603
+ if ((claimer.view as Node | null)?.nodeType === 1) {
2604
+ elements.add(claimer.view as Element);
2605
+ }
2606
+ if (elements.size === 0) {
2607
+ continue;
2608
+ }
2609
+ const release = lockDragSelection(
2610
+ elements,
2611
+ this.doc.defaultView,
2612
+ this.pressSelections.get(id) ?? [],
2613
+ () => {
2614
+ this.dragHolds.delete(release);
2615
+ },
2616
+ );
2617
+ this.dragHolds.add(release);
2618
+ // A press lock the contact took at its press stays in `lockedBy`. The
2619
+ // hold sits beside it, and the contact's end releases both.
2620
+ if (!this.lockedBy.has(id)) {
2621
+ this.lockedBy.set(id, release);
2622
+ }
2623
+ this.claimHolds.set(id, release);
2624
+ }
2625
+ }
2626
+
2627
+ /**
2628
+ * Claimed contacts the arena gave back. Their drag holds end at once, so
2629
+ * the browser's drag selects again. A contact's press lock (lockSelection)
2630
+ * is not a drag hold and stays.
2631
+ */
2632
+ private releaseClaimedDrags(ids: ReadonlyArray<number>): void {
2633
+ for (const id of ids) {
2634
+ const release = this.claimHolds.get(id);
2635
+ if (release == null) {
2636
+ continue;
2637
+ }
2638
+ this.claimHolds.delete(id);
2639
+ if (this.lockedBy.get(id) === release) {
2640
+ this.lockedBy.delete(id);
2641
+ }
2642
+ release({isCancel: true});
2643
+ }
2644
+ }
2645
+
2646
+ /**
2647
+ * End the trackpad pan a recognizer still drives, when the page loses focus
2648
+ * or hides, and return its contacts to cancel. The rest of its stream is
2649
+ * the page's until the stream ends, as a touch the browser takes is. A
2650
+ * ctrl-wheel pinch keeps its stream, so the page never zooms for it. A
2651
+ * gesture the page keeps stays as it is. So does a pan its lift released:
2652
+ * its momentum stays latched and prevented until the stream ends.
2653
+ */
2654
+ private dropDrivenWheel(): ReadonlyArray<number> {
2655
+ const gesture = this.wheel;
2656
+ if (
2657
+ gesture == null ||
2658
+ gesture.kind !== 'pan' ||
2659
+ gesture.ids.length === 0 ||
2660
+ gesture.follow?.lifted === true
2661
+ ) {
2662
+ return [];
2663
+ }
2664
+ const kept: WheelGesture = {...gesture, ids: [], takers: []};
2665
+ delete kept.follow;
2666
+ this.wheel = kept;
2667
+ this.stopWheelLatch?.();
2668
+ this.stopWheelLatch = null;
2669
+ return gesture.ids;
2670
+ }
2671
+
2672
+ /**
2673
+ * End the wheel gesture in progress. On its own timer, it ends its end
2674
+ * window after its last wheel. A taker that leaves (a detach, a disable)
2675
+ * passes that last wheel's time. An input that ends it sooner (a press,
2676
+ * the next gesture's first wheel) passes its own time. Both times are on
2677
+ * the wheels' time base (eventTime), never the host's clock, which need
2678
+ * not share it.
2679
+ */
2680
+ private endWheel(endTime?: number): void {
2681
+ const gesture = this.wheel;
2682
+ this.stopWheelLatch?.();
2683
+ this.stopWheelLatch = null;
2684
+ if (gesture == null) {
2685
+ return;
2686
+ }
2687
+ this.wheel = null;
2688
+ if (gesture.timer != null) {
2689
+ this.clock.clearTimeout(gesture.timer);
2690
+ }
2691
+ if (gesture.ids.length === 0 || gesture.follow?.lifted) {
2692
+ return;
2693
+ }
2694
+ // Fingers that stop without a lift end on their travel, where it runs
2695
+ // ahead of what the pan shows. What the undecided stretch showed past it
2696
+ // stays, as a step back would show.
2697
+ if (gesture.follow != null) {
2698
+ const f = gesture.follow;
2699
+ const x = ahead(gesture.x, f.trueX - f.heldX, f.speedX);
2700
+ const y = ahead(gesture.y, f.trueY - f.heldY, f.speedY);
2701
+ if (x !== gesture.x || y !== gesture.y) {
2702
+ gesture.x = x;
2703
+ gesture.y = y;
2704
+ this.arena.moved([{id: gesture.ids[0], x, y, time: gesture.last}]);
2705
+ }
2706
+ }
2707
+ // A trackpad scroll whose fingers stop releases still, past the
2708
+ // velocity's stillness interval. A pinch releases 100 ms after its last
2709
+ // wheel, and a lift released the pan already. The release is never later
2710
+ // than the input that ends it (a press, the next gesture's first wheel).
2711
+ // The arena's clock only moves forward, so a later stamp would move that
2712
+ // input's times too.
2713
+ const end = gesture.last + gesture.endMs;
2714
+ const time =
2715
+ endTime == null ? end : Math.max(gesture.last, Math.min(end, endTime));
2716
+ this.arena.ended(
2717
+ gesture.kind === 'pan'
2718
+ ? [{id: gesture.ids[0], x: gesture.x, y: gesture.y, time}]
2719
+ : this.pinchContacts(gesture, time),
2720
+ );
2721
+ }
2722
+
2723
+ /**
2724
+ * At a mouse or pen press, mark one that lands on selectable text: once it
2725
+ * moves, it is the browser's drag, and selects text.
2726
+ * Text whose hold does something is locked at this press already
2727
+ * (lockSelection), so it is not selectable text.
2728
+ */
2729
+ private noteSelectingPress(
2730
+ id: number,
2731
+ x: number,
2732
+ y: number,
2733
+ target: EventTarget | null,
2734
+ ): void {
2735
+ if (
2736
+ !this.lockedBy.has(id) &&
2737
+ startsOnSelectableText(this.doc, x, y, target)
2738
+ ) {
2739
+ this.selectingContacts.add(id);
2740
+ }
2741
+ }
2742
+
2743
+ /**
2744
+ * Undo a contact's selection locks, its press lock and its drag hold, at
2745
+ * once for a cancel, and forget its press's selection; idempotent. A
2746
+ * drag's hold keeps its last task after a lift (lockDragSelection).
2747
+ */
2748
+ private unlockSelection(id: number, isCancel = false): void {
2749
+ const lock = this.lockedBy.get(id);
2750
+ lock?.({isCancel});
2751
+ // A press lock keeps `lockedBy` when the contact also has a drag hold.
2752
+ // Release the hold too.
2753
+ const hold = this.claimHolds.get(id);
2754
+ if (hold != null && hold !== lock) {
2755
+ hold({isCancel});
2756
+ }
1833
2757
  this.lockedBy.delete(id);
2758
+ this.claimHolds.delete(id);
2759
+ this.pressSelections.delete(id);
2760
+ this.selectingContacts.delete(id);
1834
2761
  }
1835
2762
 
1836
2763
  // --- Touch events ----------------------------------------------------------
@@ -1869,6 +2796,7 @@ export class GestureHost {
1869
2796
  }
1870
2797
 
1871
2798
  private readonly onTouchStart = (event: TouchEvent): void => {
2799
+ this.endWheel(eventTime(event));
1872
2800
  this.noteFingersDown(event);
1873
2801
  // Where the host reads a finger from its pointer events (no ontouchstart,
1874
2802
  // as after touch emulation turns on), its Touch Events would track it twice.
@@ -2554,6 +3482,7 @@ export class GestureHost {
2554
3482
  // --- Pointer events --------------------------------------------------------
2555
3483
 
2556
3484
  private readonly onPointerDown = (event: PointerEvent): void => {
3485
+ this.endWheel(eventTime(event));
2557
3486
  // A new press: whatever click is still owed to the last one is not this.
2558
3487
  disarmClickGuard(this.doc);
2559
3488
  if (!isPointerKind(event.pointerType)) {
@@ -2591,6 +3520,11 @@ export class GestureHost {
2591
3520
  if (target !== landed && target != null) {
2592
3521
  this.retargeted.set(id, {x: event.clientX, y: event.clientY, target});
2593
3522
  }
3523
+ // Before the arena admits it: a press a group claims at its own
3524
+ // pointerdown keeps the selection the page has now.
3525
+ if (event.pointerType !== 'touch') {
3526
+ this.pressSelections.set(id, selectionRangesOf(this.doc.defaultView));
3527
+ }
2594
3528
  this.arena.began([
2595
3529
  {
2596
3530
  id,
@@ -2615,6 +3549,10 @@ export class GestureHost {
2615
3549
  consumerElement,
2616
3550
  );
2617
3551
  }
3552
+ if (event.pointerType !== 'touch') {
3553
+ // The engine's own hit: the text its caret query and a drag select.
3554
+ this.noteSelectingPress(id, event.clientX, event.clientY, landed);
3555
+ }
2618
3556
  };
2619
3557
 
2620
3558
  private readonly onPointerMove = (event: PointerEvent): void => {
@@ -2766,7 +3704,7 @@ export class GestureHost {
2766
3704
  const id = this.pointerIds.get(event.pointerId);
2767
3705
  if (id != null) {
2768
3706
  this.pointerIds.delete(event.pointerId);
2769
- this.unlockSelection(id);
3707
+ this.unlockSelection(id, true);
2770
3708
  this.retargeted.delete(id);
2771
3709
  this.arena.cancelled([id], eventTime(event));
2772
3710
  }
@@ -2832,7 +3770,10 @@ export class GestureHost {
2832
3770
 
2833
3771
  private readonly onContextMenu = (event: MouseEvent): void => {
2834
3772
  for (const touch of this.arena.touches) {
2835
- if (this.arena.isClaimed(touch.id)) {
3773
+ if (
3774
+ this.arena.isClaimed(touch.id) ||
3775
+ this.arena.consumerOf(touch.id)?.holdsContextMenu?.(touch.id) === true
3776
+ ) {
2836
3777
  event.preventDefault();
2837
3778
  return;
2838
3779
  }
@@ -3007,7 +3948,7 @@ export class GestureHost {
3007
3948
  private cancelEverything(time: number): void {
3008
3949
  const ids = [...this.pointerIds.values()];
3009
3950
  for (const id of ids) {
3010
- this.unlockSelection(id);
3951
+ this.unlockSelection(id, true);
3011
3952
  }
3012
3953
  for (const identifier of [...this.touchIds.keys()]) {
3013
3954
  // The browser may keep the touch alive: its lift must not click.
@@ -3024,6 +3965,8 @@ export class GestureHost {
3024
3965
  this.unpairedTouches.clear();
3025
3966
  this.lastFed.clear();
3026
3967
  this.pointerFed.clear();
3968
+ // A trackpad pan a recognizer still drives is cancelled with them.
3969
+ ids.push(...this.dropDrivenWheel());
3027
3970
  if (ids.length > 0) {
3028
3971
  this.arena.cancelled(ids, time);
3029
3972
  }