@swiftbrowser/web 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
package/src/renderer.ts CHANGED
@@ -151,7 +151,8 @@ import { VideoEvents, applyVideo, stopVideos } from './video';
151
151
  import { PinchController } from './pinch';
152
152
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
153
153
  import { scrollToTarget } from './scrollTo';
154
- import { POSITION_SETTLE_MS, anchorPoint, positionTarget, type PositionCandidate } from './scrollPosition';
154
+ import { POSITION_SETTLE_MS, POSITION_SNAP_WAITS, anchorPoint, positionTarget, restsOnSnapPoint, type PositionCandidate } from './scrollPosition';
155
+ import { TouchPaging } from './touchPaging';
155
156
  import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
156
157
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
157
158
  import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
@@ -396,6 +397,7 @@ export class Renderer {
396
397
  /** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
397
398
  private readonly externalDrops: ExternalDrops;
398
399
  private readonly pinches: PinchController;
400
+ private readonly touchPaging: TouchPaging;
399
401
  /** `video` elements' media events, as `video` events (src/video.ts). */
400
402
  private readonly videoEvents: VideoEvents;
401
403
  private readonly measurer = new CanvasTextMeasurer();
@@ -884,6 +886,12 @@ export class Renderer {
884
886
  this.externalDrops = new ExternalDrops(host);
885
887
  // Two-finger gestures: MagnifyGesture and RotateGesture (src/pinch.ts).
886
888
  this.pinches = new PinchController(host);
889
+ // A finger on a paging scroll view pages it as iOS does (src/touchPaging.ts);
890
+ // the view it drives reports its position once the drive rests it on a page.
891
+ this.touchPaging = new TouchPaging(root, (el) => {
892
+ this.heldScrolls.delete(el);
893
+ this.armPositionReport(el);
894
+ });
887
895
  root.addEventListener('change', this.onChange);
888
896
  root.addEventListener('pointerdown', this.onSliderPointerDown);
889
897
  if (typeof ResizeObserver !== 'undefined') {
@@ -1588,6 +1596,7 @@ export class Renderer {
1588
1596
  this.pressEffects.dispose();
1589
1597
  this.externalDrops.dispose();
1590
1598
  this.pinches.dispose();
1599
+ this.touchPaging.dispose();
1591
1600
  this.videoEvents.dispose();
1592
1601
  this.resizeObserver?.disconnect();
1593
1602
  this.hostObserver?.disconnect();
@@ -1711,11 +1720,14 @@ export class Renderer {
1711
1720
  }
1712
1721
  };
1713
1722
 
1714
- /** The last finger lifted: the held scroll views settle from here. */
1723
+ /** The last finger lifted: the held scroll views settle from here (one the finger paged, once its run rests). */
1715
1724
  private readonly onPositionTouchEnd = (event: Event): void => {
1716
1725
  if ((event as TouchEvent).touches?.length > 0) return;
1717
- for (const el of this.heldScrolls) this.armPositionReport(el);
1718
- this.heldScrolls.clear();
1726
+ for (const el of this.heldScrolls) {
1727
+ if (this.touchPaging.drives(el)) continue;
1728
+ this.heldScrolls.delete(el);
1729
+ this.armPositionReport(el);
1730
+ }
1719
1731
  };
1720
1732
 
1721
1733
  private armPositionReport(el: HTMLElement): void {
@@ -1731,9 +1743,13 @@ export class Renderer {
1731
1743
  * Which scroll target of a settled scroll view is at its anchor point: the
1732
1744
  * `scrollID` boxes inside it whose nearest scrolling ancestor it is, along
1733
1745
  * the axis it scrolls on (the vertical one when it scrolls both ways). Sent
1734
- * to that target when it is not the one reported last.
1746
+ * to that target when it is not the one reported last. A snapping scroll
1747
+ * view that settled off its snap points is still between a gesture and
1748
+ * the browser's snap animation: it waits (a bounded number of settles)
1749
+ * until the animation has carried it onto one, so the target reported is
1750
+ * the page the browser chose, never one passing by mid-snap.
1735
1751
  */
1736
- private reportPosition(el: HTMLElement): void {
1752
+ private reportPosition(el: HTMLElement, waited = 0): void {
1737
1753
  const pending = this.positionTimers.get(el);
1738
1754
  if (pending !== undefined) window.clearTimeout(pending);
1739
1755
  this.positionTimers.delete(el);
@@ -1743,7 +1759,8 @@ export class Renderer {
1743
1759
  const anchor = node.props['positionAnchor'] as { x?: unknown; y?: unknown } | undefined;
1744
1760
  const fraction = horizontal ? anchor?.x : anchor?.y;
1745
1761
  const box = el.getBoundingClientRect();
1746
- const point = anchorPoint(horizontal ? { start: box.left, length: box.width } : { start: box.top, length: box.height }, typeof fraction === 'number' ? fraction : 0);
1762
+ const viewport = horizontal ? { start: box.left, length: box.width } : { start: box.top, length: box.height };
1763
+ const point = anchorPoint(viewport, typeof fraction === 'number' ? fraction : 0);
1747
1764
  const candidates: PositionCandidate[] = [];
1748
1765
  for (const target of el.querySelectorAll<HTMLElement>('[data-sb-id]')) {
1749
1766
  const id = Number(target.dataset[ID_ATTR]);
@@ -1752,6 +1769,19 @@ export class Renderer {
1752
1769
  const rect = target.getBoundingClientRect();
1753
1770
  candidates.push(horizontal ? { id, start: rect.left, length: rect.width } : { id, start: rect.top, length: rect.height });
1754
1771
  }
1772
+ if (el.dataset['sbSnap'] !== undefined && waited < POSITION_SNAP_WAITS) {
1773
+ const computed = getComputedStyle(el);
1774
+ const padding = parseFloat(horizontal ? computed.scrollPaddingLeft : computed.scrollPaddingTop) || 0;
1775
+ const offset = horizontal ? el.scrollLeft : el.scrollTop;
1776
+ const max = horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
1777
+ if (!restsOnSnapPoint(candidates, viewport.start + padding, viewport.length, offset, max)) {
1778
+ this.positionTimers.set(
1779
+ el,
1780
+ window.setTimeout(() => this.reportPosition(el, waited + 1), POSITION_SETTLE_MS),
1781
+ );
1782
+ return;
1783
+ }
1784
+ }
1755
1785
  const target = positionTarget(candidates, point);
1756
1786
  if (target === null || this.reportedPositions.get(el) === target) return;
1757
1787
  this.reportedPositions.set(el, target);
@@ -1778,10 +1808,19 @@ export class Renderer {
1778
1808
  .map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
1779
1809
  .filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
1780
1810
  .sort((a, b) => a.command.serial - b.command.serial);
1811
+ // A page a finger set running gives way to the app's commands; a drag in progress carries on across them.
1812
+ const fresh = commands.some(({ command }) => command.serial > this.scrollSerial);
1813
+ if (fresh) this.touchPaging.beforeScrollCommands();
1781
1814
  for (const { id, command } of commands) {
1782
1815
  if (command.serial <= this.scrollSerial) continue;
1783
1816
  scrollToTarget(this, result, id, command, animated);
1817
+ // The scroll views it moved report their position afresh when they next settle, even one they
1818
+ // reported last: the app set its binding itself, and where the view rests may not be it.
1819
+ for (let scroller = this.elements.get(id)?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list'); scroller; scroller = scroller.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null) {
1820
+ this.reportedPositions.delete(scroller);
1821
+ }
1784
1822
  }
1823
+ if (fresh) this.touchPaging.afterScrollCommands();
1785
1824
  for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
1786
1825
  }
1787
1826
 
@@ -17,6 +17,37 @@ export interface PositionCandidate {
17
17
  /** A settled scroll is one with no `scroll` event for this long (`scrollend`, where the browser has it, ends the wait). */
18
18
  export const POSITION_SETTLE_MS = 120;
19
19
 
20
+ /**
21
+ * A snapping scroll view that settles off its snap points is between a
22
+ * gesture and the browser's snap animation (a trackpad swipe ended, the
23
+ * snap not yet begun): it waits this many settles more for the animation
24
+ * to carry it onto one before reporting where it is.
25
+ */
26
+ export const POSITION_SNAP_WAITS = 6;
27
+
28
+ /** How far from a snap point still counts as on it (subpixel scroll offsets). */
29
+ const SNAP_TOLERANCE = 1;
30
+
31
+ /**
32
+ * Whether a snapping scroll view (`scrollTargetBehavior`) is at rest on a
33
+ * snap point, so that the target at its anchor is the one the browser
34
+ * chose rather than one passing by mid-snap: a target starts at the
35
+ * snapport's start (`start` is the viewport's start plus its scroll
36
+ * padding), or a target longer than the viewport covers the whole of it
37
+ * (the browser lets the view rest anywhere such a target fills), or the
38
+ * view is at either end of its range (the last snap point may be out of
39
+ * reach).
40
+ */
41
+ export function restsOnSnapPoint(candidates: readonly PositionCandidate[], start: number, viewportLength: number, offset: number, maxOffset: number): boolean {
42
+ if (offset <= SNAP_TOLERANCE || offset >= maxOffset - SNAP_TOLERANCE) return true;
43
+ for (const c of candidates) {
44
+ if (c.length <= 0) continue;
45
+ if (Math.abs(c.start - start) <= SNAP_TOLERANCE) return true;
46
+ if (c.start <= start + SNAP_TOLERANCE && c.start + c.length >= start + viewportLength - SNAP_TOLERANCE && c.length > viewportLength + SNAP_TOLERANCE) return true;
47
+ }
48
+ return false;
49
+ }
50
+
20
51
  /**
21
52
  * The target at `point` along the axis: the one whose extent holds the point
22
53
  * (the last of several, as a later sibling draws over an earlier one), else
package/src/styles.css CHANGED
@@ -740,6 +740,28 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
740
740
  scroll-snap-stop: always;
741
741
  }
742
742
 
743
+ /* A finger pages a paging scroll view as iOS does (src/touchPaging.ts): the
744
+ browser keeps off its axis and the page drives the offset; a pan along
745
+ the other axis still scrolls what the view sits in. */
746
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="horizontal"] {
747
+ touch-action: pan-y pinch-zoom;
748
+ }
749
+
750
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="vertical"] {
751
+ touch-action: pan-x pinch-zoom;
752
+ }
753
+
754
+ /* Nothing inside widens the pans the browser may make along the paging axis:
755
+ a button's `manipulation` would let Safari pan the view itself under a
756
+ quick swipe that starts on it. */
757
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="horizontal"] * {
758
+ touch-action: pan-y pinch-zoom;
759
+ }
760
+
761
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="vertical"] * {
762
+ touch-action: pan-x pinch-zoom;
763
+ }
764
+
743
765
  .sb-scroll-content,
744
766
  .sb-list-content {
745
767
  position: relative;
@@ -0,0 +1,373 @@
1
+ /**
2
+ * iOS paging under a finger for `.scrollTargetBehavior(.paging)`
3
+ * (docs/ops-protocol.md, "Scroll targets"). A `UIScrollView` with paging
4
+ * follows the finger and, when it lifts, goes one page in the direction of
5
+ * a flick however short the drag was, or to the nearest page from a slow
6
+ * release (half a page across is the next page), with its own deceleration.
7
+ * CSS scroll snapping chooses by the projected end of the fling instead, so
8
+ * a short flick in Safari and Chrome falls back to the page it left. On a
9
+ * touch device the browser is kept off the paging axis (`touch-action` in
10
+ * styles.css) and this controller drives the scroll offset: 1:1 with the
11
+ * finger, then an eased run to the page `pagingDestination` chooses. Wheel
12
+ * and trackpad scrolling stay native, snapped by CSS.
13
+ */
14
+
15
+ /** A lift moving at least this fast (px per ms) is a flick: one page on in its direction. */
16
+ export const FLICK_VELOCITY = 0.3;
17
+ /** The finger's velocity is measured over its last moves within this window; a pause before the lift is no flick. */
18
+ export const FLICK_WINDOW_MS = 100;
19
+ /** How far a flick carries on inside a page longer than the viewport (its velocity times this). */
20
+ export const FLICK_RUN_MS = 250;
21
+ /**
22
+ * A touch is a drag once it moved this far; the larger axis of that first
23
+ * move decides whose gesture it is. Small, and under Safari's own threshold
24
+ * for starting a pan: a move left to the browser lets it begin a pan, after
25
+ * which it cancels the touch for the page, and a drag of ours would end in
26
+ * a run from wherever the finger was.
27
+ */
28
+ export const DIRECTION_SLOP = 3;
29
+
30
+ /** Where a scroll view may rest for one page: at its start, or anywhere a page longer than the viewport fills it (`start === end` for a page the viewport's size). */
31
+ export interface RestInterval {
32
+ start: number;
33
+ end: number;
34
+ }
35
+
36
+ export interface TouchSample {
37
+ t: number;
38
+ pos: number;
39
+ }
40
+
41
+ /** The rest intervals of pages (offsets along the axis in scroll coordinates), in order; pages of no length are left out. */
42
+ export function restIntervals(pages: readonly { start: number; length: number }[], viewportLength: number, maxOffset: number): RestInterval[] {
43
+ const clamp = (v: number) => Math.min(maxOffset, Math.max(0, v));
44
+ return pages
45
+ .filter((p) => p.length > 0)
46
+ .map((p) => ({ start: clamp(p.start), end: clamp(Math.max(p.start, p.start + p.length - viewportLength)) }))
47
+ .sort((a, b) => a.start - b.start);
48
+ }
49
+
50
+ /**
51
+ * The finger's velocity (px per ms) at the lift from its last moves: the
52
+ * change over the samples inside the window before `now`; zero after a
53
+ * pause longer than the window, or with fewer than two samples in it.
54
+ */
55
+ export function flickVelocity(samples: readonly TouchSample[], now: number): number {
56
+ const recent = samples.filter((s) => now - s.t <= FLICK_WINDOW_MS);
57
+ if (recent.length < 2) return 0;
58
+ const first = recent[0]!;
59
+ const last = recent[recent.length - 1]!;
60
+ if (last.t <= first.t) return 0;
61
+ return (last.pos - first.pos) / (last.t - first.t);
62
+ }
63
+
64
+ /**
65
+ * The offset to run to when the finger lifts at `offset` with the scroll
66
+ * offset changing at `velocity` (px per ms, positive toward later pages).
67
+ * A flick goes one page on in its direction, from the page the offset is
68
+ * on; inside a page longer than the viewport it runs on as far as its
69
+ * momentum carries, to the page's edge at most. A slow release goes to
70
+ * the nearest rest: for pages the viewport's size, the page more than
71
+ * half across.
72
+ */
73
+ export function pagingDestination(offset: number, velocity: number, intervals: readonly RestInterval[], maxOffset: number): number {
74
+ const clamp = (v: number) => Math.min(maxOffset, Math.max(0, v));
75
+ if (intervals.length === 0) return clamp(offset);
76
+ if (velocity >= FLICK_VELOCITY) {
77
+ // The page the offset is on: the last starting at or before it.
78
+ let at = 0;
79
+ for (let i = 0; i < intervals.length; i++) if (intervals[i]!.start <= offset + 1) at = i;
80
+ const page = intervals[at]!;
81
+ if (offset < page.end - 1) return clamp(Math.min(offset + velocity * FLICK_RUN_MS, page.end));
82
+ const next = intervals[at + 1];
83
+ return clamp(next ? next.start : page.end);
84
+ }
85
+ if (velocity <= -FLICK_VELOCITY) {
86
+ // The page the offset is on: the first ending at or after it.
87
+ let at = intervals.length - 1;
88
+ for (let i = intervals.length - 1; i >= 0; i--) if (intervals[i]!.end >= offset - 1) at = i;
89
+ const page = intervals[at]!;
90
+ if (offset > page.start + 1) return clamp(Math.max(offset + velocity * FLICK_RUN_MS, page.start));
91
+ const previous = intervals[at - 1];
92
+ return clamp(previous ? previous.end : page.start);
93
+ }
94
+ let best = offset;
95
+ let distance = Infinity;
96
+ for (const i of intervals) {
97
+ const rest = Math.min(i.end, Math.max(i.start, offset));
98
+ const d = Math.abs(offset - rest);
99
+ if (d < distance) {
100
+ distance = d;
101
+ best = rest;
102
+ }
103
+ }
104
+ return clamp(best);
105
+ }
106
+
107
+ /** The eased run to a page: ease-out, 200...450 ms by distance. */
108
+ export function runDuration(distance: number): number {
109
+ return Math.min(450, 200 + Math.abs(distance) * 0.3);
110
+ }
111
+
112
+ export function easeOut(t: number): number {
113
+ const u = 1 - Math.min(1, Math.max(0, t));
114
+ return 1 - u * u * u;
115
+ }
116
+
117
+ interface Gesture {
118
+ el: HTMLElement;
119
+ horizontal: boolean;
120
+ startX: number;
121
+ startY: number;
122
+ /** Null until the drag passed the slop; then whether it is ours (along the paging axis) or the browser's. */
123
+ ours: boolean | null;
124
+ /** The scroll offset where our drag took over. */
125
+ from: number;
126
+ /** The finger position (along the axis) where our drag took over. */
127
+ fromPos: number;
128
+ samples: TouchSample[];
129
+ /** The page under the leading edge as of the last move, to find again after the app's scroll commands. */
130
+ page: Page | null;
131
+ }
132
+
133
+ interface Run {
134
+ el: HTMLElement;
135
+ frame: number;
136
+ }
137
+
138
+ /** Where a drag was when the app's scroll commands began: the offset, and the page under it to find again after. */
139
+ interface Held {
140
+ el: HTMLElement;
141
+ offset: number;
142
+ page: Page | null;
143
+ }
144
+
145
+ const PAGING_SELECTOR = '.sb-scrollview[data-sb-snap="paging"]';
146
+
147
+ export interface Page {
148
+ element: Element;
149
+ start: number;
150
+ length: number;
151
+ }
152
+
153
+ /** The page whose extent holds `offset` (the leading edge), if any. */
154
+ function pageAt(el: HTMLElement, horizontal: boolean, offset: number): Page | null {
155
+ return pagesOf(el, horizontal).find((p) => p.start <= offset + 1 && offset < p.start + p.length) ?? null;
156
+ }
157
+
158
+ /** Pages of a paging scroll view in scroll coordinates along its axis: the children of its `scrollTargetLayout` stack, else of its content. */
159
+ export function pagesOf(el: HTMLElement, horizontal: boolean): Page[] {
160
+ const targets = [...el.querySelectorAll<HTMLElement>('[data-sb-scroll-target] > *')].filter((t) => t.parentElement?.closest('.sb-scrollview, .sb-list') === el);
161
+ const children = targets.length > 0 ? targets : [...((el.firstElementChild?.children ?? []) as Iterable<Element>)];
162
+ const box = el.getBoundingClientRect();
163
+ const computed = getComputedStyle(el);
164
+ const padding = parseFloat(horizontal ? computed.scrollPaddingLeft : computed.scrollPaddingTop) || 0;
165
+ const offset = horizontal ? el.scrollLeft : el.scrollTop;
166
+ return children.map((child) => {
167
+ const rect = child.getBoundingClientRect();
168
+ return horizontal ? { element: child, start: rect.left - box.left - padding + offset, length: rect.width } : { element: child, start: rect.top - box.top - padding + offset, length: rect.height };
169
+ });
170
+ }
171
+
172
+ export class TouchPaging {
173
+ private gesture: Gesture | null = null;
174
+ private run: Run | null = null;
175
+ private held: Held | null = null;
176
+
177
+ /**
178
+ * `onRest(el)`: the view came to rest on a page after a gesture of ours
179
+ * (the renderer reports its position then; while a drive is on, a
180
+ * `scrollend` the browser fires for a frame's write is no settling).
181
+ */
182
+ constructor(
183
+ private readonly root: HTMLElement,
184
+ private readonly onRest: (el: HTMLElement) => void = () => {},
185
+ ) {
186
+ root.addEventListener('touchstart', this.onTouchStart, { passive: true });
187
+ // Not passive: a drag of ours cancels the browser's own handling of the moves (Safari pans a
188
+ // scroll view under a finger whatever `touch-action` says, and the two would fight over it).
189
+ root.addEventListener('touchmove', this.onTouchMove, { passive: false });
190
+ root.addEventListener('touchend', this.onTouchEnd, { passive: true });
191
+ root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
192
+ }
193
+
194
+ /** Whether a finger's drag along the paging axis, or the run after it, is moving `el`. */
195
+ drives(el: HTMLElement): boolean {
196
+ return (this.gesture?.el === el && this.gesture.ours === true) || this.run?.el === el;
197
+ }
198
+
199
+ /**
200
+ * The app is about to scroll (`scrollTo` commands): a run still going
201
+ * gives way to them, and a drag in progress notes where the view is and
202
+ * which page is under the finger, to carry on from there once the
203
+ * commands are done (`afterScrollCommands`).
204
+ */
205
+ beforeScrollCommands(): void {
206
+ const run = this.run;
207
+ if (run) {
208
+ this.stopRun();
209
+ this.onRest(run.el);
210
+ }
211
+ const g = this.gesture;
212
+ this.held = null;
213
+ if (!g?.ours) return;
214
+ // The page as of the drag's last move: the commands' pass has already changed the tree by now.
215
+ this.held = { el: g.el, offset: g.horizontal ? g.el.scrollLeft : g.el.scrollTop, page: g.page };
216
+ }
217
+
218
+ /**
219
+ * The app's scroll commands are done. A drag in progress keeps the
220
+ * content under the finger where it was: an app re-centring its pages
221
+ * on the one arrived at moves the content a page and scrolls the same
222
+ * page, which the command's own offset would show aligned, dropping the
223
+ * drag so far. The page's new places reach the DOM on the next frame
224
+ * (the renderer commits its layout then), so the drag carries on from
225
+ * them at the next frame or the next move, whichever comes first.
226
+ */
227
+ afterScrollCommands(): void {
228
+ const held = this.held;
229
+ if (!held) return;
230
+ const g = this.gesture;
231
+ if (!g?.ours || g.el !== held.el) {
232
+ this.held = null;
233
+ return;
234
+ }
235
+ requestAnimationFrame(() => this.carryOn());
236
+ }
237
+
238
+ /**
239
+ * The page that was under the finger is found again and the offset
240
+ * moved by what it moved; without it (a page that is gone), the drag
241
+ * carries on from the offset the commands set.
242
+ */
243
+ private carryOn(): void {
244
+ const held = this.held;
245
+ this.held = null;
246
+ const g = this.gesture;
247
+ if (!held || !g?.ours || g.el !== held.el) return;
248
+ const now = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
249
+ const page = held.page ? pagesOf(g.el, g.horizontal).find((p) => p.element === held.page!.element) : undefined;
250
+ const shift = page ? page.start - held.page!.start : now - held.offset;
251
+ g.from += shift;
252
+ if (page) {
253
+ const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
254
+ const offset = Math.min(max, Math.max(0, held.offset + shift));
255
+ if (g.horizontal) g.el.scrollLeft = offset;
256
+ else g.el.scrollTop = offset;
257
+ }
258
+ }
259
+
260
+ dispose(): void {
261
+ this.root.removeEventListener('touchstart', this.onTouchStart);
262
+ this.root.removeEventListener('touchmove', this.onTouchMove);
263
+ this.root.removeEventListener('touchend', this.onTouchEnd);
264
+ this.root.removeEventListener('touchcancel', this.onTouchEnd);
265
+ this.stopRun();
266
+ }
267
+
268
+ private readonly onTouchStart = (event: TouchEvent): void => {
269
+ this.gesture = null;
270
+ const touch = event.touches[0];
271
+ if (!touch || event.touches.length !== 1 || !(event.target instanceof Element)) return;
272
+ const el = event.target.closest<HTMLElement>(PAGING_SELECTOR);
273
+ if (!el) return;
274
+ const axes = el.dataset['sbAxes'];
275
+ if (axes !== 'horizontal' && axes !== 'vertical') return;
276
+ const horizontal = axes === 'horizontal';
277
+ this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [], page: null };
278
+ // A finger on a page still running to its place catches it: the drag is
279
+ // ours from here, and the lift runs it on to a page.
280
+ if (this.run?.el === el) {
281
+ this.stopRun();
282
+ this.gesture.ours = true;
283
+ this.gesture.from = horizontal ? el.scrollLeft : el.scrollTop;
284
+ this.gesture.fromPos = horizontal ? touch.clientX : touch.clientY;
285
+ el.style.scrollSnapType = 'none';
286
+ }
287
+ };
288
+
289
+ private readonly onTouchMove = (event: TouchEvent): void => {
290
+ const g = this.gesture;
291
+ const touch = event.touches[0];
292
+ if (!g || !touch) return;
293
+ if (this.held) this.carryOn();
294
+ const pos = g.horizontal ? touch.clientX : touch.clientY;
295
+ if (g.ours === null) {
296
+ const dx = touch.clientX - g.startX;
297
+ const dy = touch.clientY - g.startY;
298
+ if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return;
299
+ g.ours = g.horizontal ? Math.abs(dx) >= Math.abs(dy) : Math.abs(dy) > Math.abs(dx);
300
+ if (!g.ours) {
301
+ this.gesture = null;
302
+ return;
303
+ }
304
+ // Ours: the snap is off while the offset follows the finger (a snapping
305
+ // container snaps every offset it is given), back on once it rests.
306
+ g.el.style.scrollSnapType = 'none';
307
+ g.from = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
308
+ g.fromPos = pos;
309
+ }
310
+ if (event.cancelable) event.preventDefault();
311
+ const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
312
+ const offset = Math.min(max, Math.max(0, g.from - (pos - g.fromPos)));
313
+ if (g.horizontal) g.el.scrollLeft = offset;
314
+ else g.el.scrollTop = offset;
315
+ g.page = pageAt(g.el, g.horizontal, offset);
316
+ g.samples.push({ t: event.timeStamp, pos });
317
+ if (g.samples.length > 20) g.samples.shift();
318
+ };
319
+
320
+ private readonly onTouchEnd = (event: TouchEvent): void => {
321
+ const g = this.gesture;
322
+ if (!g) return;
323
+ if (event.touches.length > 0) return;
324
+ if (this.held) this.carryOn();
325
+ this.gesture = null;
326
+ if (!g.ours) return;
327
+ const el = g.el;
328
+ const max = g.horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
329
+ const offset = g.horizontal ? el.scrollLeft : el.scrollTop;
330
+ // The finger's velocity, as the offset's: a finger moving back moves the content on.
331
+ const velocity = event.type === 'touchcancel' ? 0 : -flickVelocity(g.samples, event.timeStamp);
332
+ const viewport = g.horizontal ? el.clientWidth : el.clientHeight;
333
+ const destination = pagingDestination(offset, velocity, restIntervals(pagesOf(el, g.horizontal), viewport, max), max);
334
+ this.runTo(el, g.horizontal, offset, destination);
335
+ };
336
+
337
+ /** Runs the offset to `to` with an ease-out, then lets the container snap again. */
338
+ private runTo(el: HTMLElement, horizontal: boolean, from: number, to: number): void {
339
+ this.stopRun();
340
+ const distance = to - from;
341
+ if (Math.abs(distance) < 0.5) {
342
+ el.style.scrollSnapType = '';
343
+ this.onRest(el);
344
+ return;
345
+ }
346
+ const duration = runDuration(distance);
347
+ const started = performance.now();
348
+ // The run writes the offset every frame and the last write is the page itself, whatever
349
+ // else moved the view meanwhile (the offset Safari reads back can trail a write); only an
350
+ // app's own scroll command ends it early (`interrupt`).
351
+ const step = (now: number): void => {
352
+ const t = (now - started) / duration;
353
+ const offset = t >= 1 ? to : from + distance * easeOut(t);
354
+ if (horizontal) el.scrollLeft = offset;
355
+ else el.scrollTop = offset;
356
+ if (t >= 1) {
357
+ this.run = null;
358
+ el.style.scrollSnapType = '';
359
+ this.onRest(el);
360
+ return;
361
+ }
362
+ this.run = { el, frame: requestAnimationFrame(step) };
363
+ };
364
+ this.run = { el, frame: requestAnimationFrame(step) };
365
+ }
366
+
367
+ private stopRun(): void {
368
+ if (!this.run) return;
369
+ cancelAnimationFrame(this.run.frame);
370
+ this.run.el.style.scrollSnapType = '';
371
+ this.run = null;
372
+ }
373
+ }