@swiftbrowser/web 0.9.10 → 0.9.11
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 +1 -1
- package/src/renderer.ts +37 -7
- package/src/scrollPosition.ts +31 -0
- package/src/styles.css +11 -0
- package/src/touchPaging.ts +286 -0
package/package.json
CHANGED
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)
|
|
1718
|
-
|
|
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
|
|
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);
|
package/src/scrollPosition.ts
CHANGED
|
@@ -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,17 @@ 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
|
+
|
|
743
754
|
.sb-scroll-content,
|
|
744
755
|
.sb-list-content {
|
|
745
756
|
position: relative;
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
/** A touch is a drag once it moved this far; the larger axis then decides whose gesture it is. */
|
|
22
|
+
export const DIRECTION_SLOP = 6;
|
|
23
|
+
|
|
24
|
+
/** 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). */
|
|
25
|
+
export interface RestInterval {
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TouchSample {
|
|
31
|
+
t: number;
|
|
32
|
+
pos: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The rest intervals of pages (offsets along the axis in scroll coordinates), in order; pages of no length are left out. */
|
|
36
|
+
export function restIntervals(pages: readonly { start: number; length: number }[], viewportLength: number, maxOffset: number): RestInterval[] {
|
|
37
|
+
const clamp = (v: number) => Math.min(maxOffset, Math.max(0, v));
|
|
38
|
+
return pages
|
|
39
|
+
.filter((p) => p.length > 0)
|
|
40
|
+
.map((p) => ({ start: clamp(p.start), end: clamp(Math.max(p.start, p.start + p.length - viewportLength)) }))
|
|
41
|
+
.sort((a, b) => a.start - b.start);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The finger's velocity (px per ms) at the lift from its last moves: the
|
|
46
|
+
* change over the samples inside the window before `now`; zero after a
|
|
47
|
+
* pause longer than the window, or with fewer than two samples in it.
|
|
48
|
+
*/
|
|
49
|
+
export function flickVelocity(samples: readonly TouchSample[], now: number): number {
|
|
50
|
+
const recent = samples.filter((s) => now - s.t <= FLICK_WINDOW_MS);
|
|
51
|
+
if (recent.length < 2) return 0;
|
|
52
|
+
const first = recent[0]!;
|
|
53
|
+
const last = recent[recent.length - 1]!;
|
|
54
|
+
if (last.t <= first.t) return 0;
|
|
55
|
+
return (last.pos - first.pos) / (last.t - first.t);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The offset to run to when the finger lifts at `offset` with the scroll
|
|
60
|
+
* offset changing at `velocity` (px per ms, positive toward later pages).
|
|
61
|
+
* A flick goes one page on in its direction, from the page the offset is
|
|
62
|
+
* on; inside a page longer than the viewport it runs on as far as its
|
|
63
|
+
* momentum carries, to the page's edge at most. A slow release goes to
|
|
64
|
+
* the nearest rest: for pages the viewport's size, the page more than
|
|
65
|
+
* half across.
|
|
66
|
+
*/
|
|
67
|
+
export function pagingDestination(offset: number, velocity: number, intervals: readonly RestInterval[], maxOffset: number): number {
|
|
68
|
+
const clamp = (v: number) => Math.min(maxOffset, Math.max(0, v));
|
|
69
|
+
if (intervals.length === 0) return clamp(offset);
|
|
70
|
+
if (velocity >= FLICK_VELOCITY) {
|
|
71
|
+
// The page the offset is on: the last starting at or before it.
|
|
72
|
+
let at = 0;
|
|
73
|
+
for (let i = 0; i < intervals.length; i++) if (intervals[i]!.start <= offset + 1) at = i;
|
|
74
|
+
const page = intervals[at]!;
|
|
75
|
+
if (offset < page.end - 1) return clamp(Math.min(offset + velocity * FLICK_RUN_MS, page.end));
|
|
76
|
+
const next = intervals[at + 1];
|
|
77
|
+
return clamp(next ? next.start : page.end);
|
|
78
|
+
}
|
|
79
|
+
if (velocity <= -FLICK_VELOCITY) {
|
|
80
|
+
// The page the offset is on: the first ending at or after it.
|
|
81
|
+
let at = intervals.length - 1;
|
|
82
|
+
for (let i = intervals.length - 1; i >= 0; i--) if (intervals[i]!.end >= offset - 1) at = i;
|
|
83
|
+
const page = intervals[at]!;
|
|
84
|
+
if (offset > page.start + 1) return clamp(Math.max(offset + velocity * FLICK_RUN_MS, page.start));
|
|
85
|
+
const previous = intervals[at - 1];
|
|
86
|
+
return clamp(previous ? previous.end : page.start);
|
|
87
|
+
}
|
|
88
|
+
let best = offset;
|
|
89
|
+
let distance = Infinity;
|
|
90
|
+
for (const i of intervals) {
|
|
91
|
+
const rest = Math.min(i.end, Math.max(i.start, offset));
|
|
92
|
+
const d = Math.abs(offset - rest);
|
|
93
|
+
if (d < distance) {
|
|
94
|
+
distance = d;
|
|
95
|
+
best = rest;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return clamp(best);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The eased run to a page: ease-out, 200...450 ms by distance. */
|
|
102
|
+
export function runDuration(distance: number): number {
|
|
103
|
+
return Math.min(450, 200 + Math.abs(distance) * 0.3);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function easeOut(t: number): number {
|
|
107
|
+
const u = 1 - Math.min(1, Math.max(0, t));
|
|
108
|
+
return 1 - u * u * u;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface Gesture {
|
|
112
|
+
el: HTMLElement;
|
|
113
|
+
horizontal: boolean;
|
|
114
|
+
startX: number;
|
|
115
|
+
startY: number;
|
|
116
|
+
/** Null until the drag passed the slop; then whether it is ours (along the paging axis) or the browser's. */
|
|
117
|
+
ours: boolean | null;
|
|
118
|
+
/** The scroll offset where our drag took over. */
|
|
119
|
+
from: number;
|
|
120
|
+
/** The finger position (along the axis) where our drag took over. */
|
|
121
|
+
fromPos: number;
|
|
122
|
+
samples: TouchSample[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Run {
|
|
126
|
+
el: HTMLElement;
|
|
127
|
+
frame: number;
|
|
128
|
+
/** The offset the last frame wrote: another writer moving the view ends the run. */
|
|
129
|
+
written: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const PAGING_SELECTOR = '.sb-scrollview[data-sb-snap="paging"]';
|
|
133
|
+
|
|
134
|
+
/** Pages of a paging scroll view in scroll coordinates along its axis: the children of its `scrollTargetLayout` stack, else of its content. */
|
|
135
|
+
export function pagesOf(el: HTMLElement, horizontal: boolean): { start: number; length: number }[] {
|
|
136
|
+
const targets = [...el.querySelectorAll<HTMLElement>('[data-sb-scroll-target] > *')].filter((t) => t.parentElement?.closest('.sb-scrollview, .sb-list') === el);
|
|
137
|
+
const children = targets.length > 0 ? targets : [...((el.firstElementChild?.children ?? []) as Iterable<Element>)];
|
|
138
|
+
const box = el.getBoundingClientRect();
|
|
139
|
+
const computed = getComputedStyle(el);
|
|
140
|
+
const padding = parseFloat(horizontal ? computed.scrollPaddingLeft : computed.scrollPaddingTop) || 0;
|
|
141
|
+
const offset = horizontal ? el.scrollLeft : el.scrollTop;
|
|
142
|
+
return children.map((child) => {
|
|
143
|
+
const rect = child.getBoundingClientRect();
|
|
144
|
+
return horizontal ? { start: rect.left - box.left - padding + offset, length: rect.width } : { start: rect.top - box.top - padding + offset, length: rect.height };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class TouchPaging {
|
|
149
|
+
private gesture: Gesture | null = null;
|
|
150
|
+
private run: Run | null = null;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `onRest(el)`: the view came to rest on a page after a gesture of ours
|
|
154
|
+
* (the renderer reports its position then; while a drive is on, a
|
|
155
|
+
* `scrollend` the browser fires for a frame's write is no settling).
|
|
156
|
+
*/
|
|
157
|
+
constructor(
|
|
158
|
+
private readonly root: HTMLElement,
|
|
159
|
+
private readonly onRest: (el: HTMLElement) => void = () => {},
|
|
160
|
+
) {
|
|
161
|
+
root.addEventListener('touchstart', this.onTouchStart, { passive: true });
|
|
162
|
+
root.addEventListener('touchmove', this.onTouchMove, { passive: true });
|
|
163
|
+
root.addEventListener('touchend', this.onTouchEnd, { passive: true });
|
|
164
|
+
root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a finger's drag along the paging axis, or the run after it, is moving `el`. */
|
|
168
|
+
drives(el: HTMLElement): boolean {
|
|
169
|
+
return (this.gesture?.el === el && this.gesture.ours === true) || this.run?.el === el;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
dispose(): void {
|
|
173
|
+
this.root.removeEventListener('touchstart', this.onTouchStart);
|
|
174
|
+
this.root.removeEventListener('touchmove', this.onTouchMove);
|
|
175
|
+
this.root.removeEventListener('touchend', this.onTouchEnd);
|
|
176
|
+
this.root.removeEventListener('touchcancel', this.onTouchEnd);
|
|
177
|
+
this.stopRun();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private readonly onTouchStart = (event: TouchEvent): void => {
|
|
181
|
+
this.gesture = null;
|
|
182
|
+
const touch = event.touches[0];
|
|
183
|
+
if (!touch || event.touches.length !== 1 || !(event.target instanceof Element)) return;
|
|
184
|
+
const el = event.target.closest<HTMLElement>(PAGING_SELECTOR);
|
|
185
|
+
if (!el) return;
|
|
186
|
+
const axes = el.dataset['sbAxes'];
|
|
187
|
+
if (axes !== 'horizontal' && axes !== 'vertical') return;
|
|
188
|
+
const horizontal = axes === 'horizontal';
|
|
189
|
+
this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [] };
|
|
190
|
+
// A finger on a page still running to its place catches it: the drag is
|
|
191
|
+
// ours from here, and the lift runs it on to a page.
|
|
192
|
+
if (this.run?.el === el) {
|
|
193
|
+
this.stopRun();
|
|
194
|
+
this.gesture.ours = true;
|
|
195
|
+
this.gesture.from = horizontal ? el.scrollLeft : el.scrollTop;
|
|
196
|
+
this.gesture.fromPos = horizontal ? touch.clientX : touch.clientY;
|
|
197
|
+
el.style.scrollSnapType = 'none';
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
private readonly onTouchMove = (event: TouchEvent): void => {
|
|
202
|
+
const g = this.gesture;
|
|
203
|
+
const touch = event.touches[0];
|
|
204
|
+
if (!g || !touch) return;
|
|
205
|
+
const pos = g.horizontal ? touch.clientX : touch.clientY;
|
|
206
|
+
if (g.ours === null) {
|
|
207
|
+
const dx = touch.clientX - g.startX;
|
|
208
|
+
const dy = touch.clientY - g.startY;
|
|
209
|
+
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return;
|
|
210
|
+
g.ours = g.horizontal ? Math.abs(dx) >= Math.abs(dy) : Math.abs(dy) > Math.abs(dx);
|
|
211
|
+
if (!g.ours) {
|
|
212
|
+
this.gesture = null;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Ours: the snap is off while the offset follows the finger (a snapping
|
|
216
|
+
// container snaps every offset it is given), back on once it rests.
|
|
217
|
+
g.el.style.scrollSnapType = 'none';
|
|
218
|
+
g.from = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
|
|
219
|
+
g.fromPos = pos;
|
|
220
|
+
}
|
|
221
|
+
const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
|
|
222
|
+
const offset = Math.min(max, Math.max(0, g.from - (pos - g.fromPos)));
|
|
223
|
+
if (g.horizontal) g.el.scrollLeft = offset;
|
|
224
|
+
else g.el.scrollTop = offset;
|
|
225
|
+
g.samples.push({ t: event.timeStamp, pos });
|
|
226
|
+
if (g.samples.length > 20) g.samples.shift();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
private readonly onTouchEnd = (event: TouchEvent): void => {
|
|
230
|
+
const g = this.gesture;
|
|
231
|
+
if (!g) return;
|
|
232
|
+
if (event.touches.length > 0) return;
|
|
233
|
+
this.gesture = null;
|
|
234
|
+
if (!g.ours) return;
|
|
235
|
+
const el = g.el;
|
|
236
|
+
const max = g.horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
|
|
237
|
+
const offset = g.horizontal ? el.scrollLeft : el.scrollTop;
|
|
238
|
+
// The finger's velocity, as the offset's: a finger moving back moves the content on.
|
|
239
|
+
const velocity = event.type === 'touchcancel' ? 0 : -flickVelocity(g.samples, event.timeStamp);
|
|
240
|
+
const viewport = g.horizontal ? el.clientWidth : el.clientHeight;
|
|
241
|
+
const destination = pagingDestination(offset, velocity, restIntervals(pagesOf(el, g.horizontal), viewport, max), max);
|
|
242
|
+
this.runTo(el, g.horizontal, offset, destination);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Runs the offset to `to` with an ease-out, then lets the container snap again. */
|
|
246
|
+
private runTo(el: HTMLElement, horizontal: boolean, from: number, to: number): void {
|
|
247
|
+
this.stopRun();
|
|
248
|
+
const distance = to - from;
|
|
249
|
+
if (Math.abs(distance) < 0.5) {
|
|
250
|
+
el.style.scrollSnapType = '';
|
|
251
|
+
this.onRest(el);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const duration = runDuration(distance);
|
|
255
|
+
const started = performance.now();
|
|
256
|
+
const step = (now: number): void => {
|
|
257
|
+
const run = this.run;
|
|
258
|
+
const current = horizontal ? el.scrollLeft : el.scrollTop;
|
|
259
|
+
// Another writer moved the view (an app re-centring on the page): the run is over.
|
|
260
|
+
if (run && Math.abs(current - run.written) > 1) {
|
|
261
|
+
this.stopRun();
|
|
262
|
+
this.onRest(el);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const t = (now - started) / duration;
|
|
266
|
+
const offset = t >= 1 ? to : from + distance * easeOut(t);
|
|
267
|
+
if (horizontal) el.scrollLeft = offset;
|
|
268
|
+
else el.scrollTop = offset;
|
|
269
|
+
if (t >= 1) {
|
|
270
|
+
this.run = null;
|
|
271
|
+
el.style.scrollSnapType = '';
|
|
272
|
+
this.onRest(el);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.run = { el, frame: requestAnimationFrame(step), written: offset };
|
|
276
|
+
};
|
|
277
|
+
this.run = { el, frame: requestAnimationFrame(step), written: from };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private stopRun(): void {
|
|
281
|
+
if (!this.run) return;
|
|
282
|
+
cancelAnimationFrame(this.run.frame);
|
|
283
|
+
this.run.el.style.scrollSnapType = '';
|
|
284
|
+
this.run = null;
|
|
285
|
+
}
|
|
286
|
+
}
|