@swiftbrowser/web 0.9.9 → 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/layout/engine.ts +44 -27
- package/src/renderer.ts +82 -11
- package/src/scrollPosition.ts +31 -0
- package/src/styles.css +11 -0
- package/src/touchPaging.ts +286 -0
package/package.json
CHANGED
package/src/layout/engine.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* renderer's element tree (docs/ops-protocol.md, "Layout engine semantics").
|
|
4
4
|
*
|
|
5
5
|
* `measure(node, proposal)` reports the size a node wants for a proposal and
|
|
6
|
-
* is memoized per (node, proposal); `place(node, proposal, x, y)`
|
|
7
|
-
* frame and places the children using the same memoized plans.
|
|
8
|
-
* line limit, text alignment, enclosing stack axis, list row)
|
|
9
|
-
* tree like SwiftUI's environment and is a pure function of
|
|
10
|
-
* ancestors, so it is not part of the memo key
|
|
6
|
+
* is memoized per (node, proposal, container); `place(node, proposal, x, y)`
|
|
7
|
+
* records the frame and places the children using the same memoized plans.
|
|
8
|
+
* Context (font, line limit, text alignment, enclosing stack axis, list row)
|
|
9
|
+
* flows down the tree like SwiftUI's environment and is a pure function of
|
|
10
|
+
* the node's ancestors, so it is not part of the memo key; the container
|
|
11
|
+
* (`containerRelativeFrame`) is, as a scroll view's viewport is its own
|
|
12
|
+
* proposal and differs between the widths a stack tries it at.
|
|
11
13
|
*
|
|
12
14
|
* Phase 9: a `LayoutPass` lives across commits. The renderer tells it which
|
|
13
15
|
* nodes changed (`invalidate`, `remove`); a changed node, its ancestors (their
|
|
@@ -978,6 +980,18 @@ function proposalKey(p: Proposal): string {
|
|
|
978
980
|
return `${p.width === null ? 'n' : p.width}|${p.height === null ? 'n' : p.height}`;
|
|
979
981
|
}
|
|
980
982
|
|
|
983
|
+
/**
|
|
984
|
+
* The memo key of a plan or placement: the proposal and the container
|
|
985
|
+
* (`containerRelativeFrame`) it was made under. The container is the screen
|
|
986
|
+
* everywhere but inside a scroll view, whose viewport is its proposal: a
|
|
987
|
+
* stack measuring the scroll view at zero, at its ideal and at its final
|
|
988
|
+
* width makes three containers, and a page sized by the container must not
|
|
989
|
+
* keep the size the first measurement gave it.
|
|
990
|
+
*/
|
|
991
|
+
function planKey(ctx: Context, p: Proposal): string {
|
|
992
|
+
return `${proposalKey(p)}@${ctx.container.width}x${ctx.container.height}`;
|
|
993
|
+
}
|
|
994
|
+
|
|
981
995
|
/**
|
|
982
996
|
* Snap a frame to whole CSS pixels: both edges floor (with an epsilon for
|
|
983
997
|
* float noise), so nested and adjacent frames never overlap by a fraction and
|
|
@@ -1100,6 +1114,8 @@ interface ListPlan {
|
|
|
1100
1114
|
interface ScrollPlan {
|
|
1101
1115
|
size: Size;
|
|
1102
1116
|
content: LayoutNode | null;
|
|
1117
|
+
/** The content's context: the scroll view's, with the viewport as the container. */
|
|
1118
|
+
cctx: Context;
|
|
1103
1119
|
contentProposal: Proposal;
|
|
1104
1120
|
contentSize: Size;
|
|
1105
1121
|
}
|
|
@@ -1484,13 +1500,13 @@ export class LayoutPass {
|
|
|
1484
1500
|
// Memoization and context
|
|
1485
1501
|
// -------------------------------------------------------------------------
|
|
1486
1502
|
|
|
1487
|
-
private plan<T>(node: LayoutNode, proposal: Proposal, compute: () => T): T {
|
|
1503
|
+
private plan<T>(node: LayoutNode, ctx: Context, proposal: Proposal, compute: () => T): T {
|
|
1488
1504
|
let byProposal = this.memo.get(node);
|
|
1489
1505
|
if (!byProposal) {
|
|
1490
1506
|
byProposal = new Map();
|
|
1491
1507
|
this.memo.set(node, byProposal);
|
|
1492
1508
|
}
|
|
1493
|
-
const key =
|
|
1509
|
+
const key = planKey(ctx, proposal);
|
|
1494
1510
|
const cached = byProposal.get(key);
|
|
1495
1511
|
if (cached !== undefined) return cached as T;
|
|
1496
1512
|
const value = compute();
|
|
@@ -1765,7 +1781,7 @@ export class LayoutPass {
|
|
|
1765
1781
|
}
|
|
1766
1782
|
|
|
1767
1783
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
1768
|
-
return this.plan(node, p, () => {
|
|
1784
|
+
return this.plan(node, ctx, p, () => {
|
|
1769
1785
|
const runs = node.props['runs'];
|
|
1770
1786
|
if (Array.isArray(runs)) return this.richTextPlan(runs, ctx, p);
|
|
1771
1787
|
const text = str(node.props['text']) ?? '';
|
|
@@ -1974,7 +1990,7 @@ export class LayoutPass {
|
|
|
1974
1990
|
* non-empty; the width is the proposal, or the widest row.
|
|
1975
1991
|
*/
|
|
1976
1992
|
private pickerPlan(node: LayoutNode, ctx: Context, p: Proposal): PickerPlan {
|
|
1977
|
-
return this.plan(node, p, () => {
|
|
1993
|
+
return this.plan(node, ctx, p, () => {
|
|
1978
1994
|
const options = readOptions(node);
|
|
1979
1995
|
const selected = readSelected(node, options.length);
|
|
1980
1996
|
const style = str(node.props['style']);
|
|
@@ -2089,7 +2105,7 @@ export class LayoutPass {
|
|
|
2089
2105
|
// -------------------------------------------------------------------------
|
|
2090
2106
|
|
|
2091
2107
|
private stackPlan(node: LayoutNode, ctx: Context, p: Proposal, axis: Axis): StackPlan {
|
|
2092
|
-
return this.plan(node, p, () => {
|
|
2108
|
+
return this.plan(node, ctx, p, () => {
|
|
2093
2109
|
const cctx = this.childContext(node, ctx);
|
|
2094
2110
|
return this.layoutStack(node.children, cctx, p, axis, this.stackGaps(node, cctx, axis));
|
|
2095
2111
|
});
|
|
@@ -2390,7 +2406,7 @@ export class LayoutPass {
|
|
|
2390
2406
|
* frame (outermost) > padding > fixedSize > aspectRatio > child.
|
|
2391
2407
|
*/
|
|
2392
2408
|
private styledPlan(node: LayoutNode, ctx: Context, p: Proposal): StyledPlan {
|
|
2393
|
-
return this.plan(node, p, () => {
|
|
2409
|
+
return this.plan(node, ctx, p, () => {
|
|
2394
2410
|
const style = styleOf(node);
|
|
2395
2411
|
let frame = readFrame(style['frame']);
|
|
2396
2412
|
// Phase 12 `.containerRelativeFrame`: an affine size against the nearest
|
|
@@ -2542,7 +2558,7 @@ export class LayoutPass {
|
|
|
2542
2558
|
}
|
|
2543
2559
|
|
|
2544
2560
|
private scrollPlan(node: LayoutNode, ctx: Context, p: Proposal): ScrollPlan {
|
|
2545
|
-
return this.plan(node, p, () => {
|
|
2561
|
+
return this.plan(node, ctx, p, () => {
|
|
2546
2562
|
const axes = this.scrollAxes(node);
|
|
2547
2563
|
const content = this.contentOf(node, axes.horizontal && !axes.vertical ? 'horizontal' : 'vertical');
|
|
2548
2564
|
// Phase 12: the viewport is the container `containerRelativeFrame` measures against.
|
|
@@ -2562,7 +2578,7 @@ export class LayoutPass {
|
|
|
2562
2578
|
width: p.width === null ? contentSize.width : p.width,
|
|
2563
2579
|
height: p.height === null ? contentSize.height : p.height,
|
|
2564
2580
|
};
|
|
2565
|
-
return { size, content, contentProposal, contentSize };
|
|
2581
|
+
return { size, content, cctx, contentProposal, contentSize };
|
|
2566
2582
|
});
|
|
2567
2583
|
}
|
|
2568
2584
|
|
|
@@ -2571,7 +2587,7 @@ export class LayoutPass {
|
|
|
2571
2587
|
// -------------------------------------------------------------------------
|
|
2572
2588
|
|
|
2573
2589
|
private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
2574
|
-
return this.plan(node, p, () => {
|
|
2590
|
+
return this.plan(node, ctx, p, () => {
|
|
2575
2591
|
const { screen } = this.env;
|
|
2576
2592
|
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
2577
2593
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
@@ -2775,17 +2791,17 @@ export class LayoutPass {
|
|
|
2775
2791
|
* Phase 9: placed before, same proposal, same origin, nothing in the
|
|
2776
2792
|
* subtree invalidated since: the last run's output still holds.
|
|
2777
2793
|
*/
|
|
2778
|
-
private placedAlready(node: LayoutNode, p: Proposal, x: number, y: number): boolean {
|
|
2794
|
+
private placedAlready(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): boolean {
|
|
2779
2795
|
const last = this.placed.get(node);
|
|
2780
|
-
return last !== undefined && last.key ===
|
|
2796
|
+
return last !== undefined && last.key === planKey(ctx, p) && last.x === x && last.y === y;
|
|
2781
2797
|
}
|
|
2782
2798
|
|
|
2783
2799
|
place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2784
|
-
if (this.placedAlready(node, p, x, y)) {
|
|
2800
|
+
if (this.placedAlready(node, ctx, p, x, y)) {
|
|
2785
2801
|
this.stats.skipped += 1;
|
|
2786
2802
|
return;
|
|
2787
2803
|
}
|
|
2788
|
-
const key =
|
|
2804
|
+
const key = planKey(ctx, p);
|
|
2789
2805
|
this.placed.set(node, { key, x, y });
|
|
2790
2806
|
this.stats.placed += 1;
|
|
2791
2807
|
if (node.id >= 0) {
|
|
@@ -3018,7 +3034,8 @@ export class LayoutPass {
|
|
|
3018
3034
|
if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
|
|
3019
3035
|
if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
|
|
3020
3036
|
this.setChrome(node, scrollChrome);
|
|
3021
|
-
|
|
3037
|
+
// The content is placed under the context it was measured in: its container is the viewport.
|
|
3038
|
+
if (plan.content) this.place(plan.content, plan.cctx, plan.contentProposal, content.x, content.y);
|
|
3022
3039
|
return;
|
|
3023
3040
|
}
|
|
3024
3041
|
case 'list':
|
|
@@ -3291,7 +3308,7 @@ export class LayoutPass {
|
|
|
3291
3308
|
const rowY = y + row.content.y;
|
|
3292
3309
|
// A row that is skipped keeps the chrome it set itself when it was
|
|
3293
3310
|
// placed (a link's chevron and label); only the list's keys are new.
|
|
3294
|
-
const own = this.placedAlready(row.node, row.proposal, rowX, rowY) && row.node.id >= 0
|
|
3311
|
+
const own = this.placedAlready(row.node, rowCtx, row.proposal, rowX, rowY) && row.node.id >= 0
|
|
3295
3312
|
? this.chrome.get(row.node.id)
|
|
3296
3313
|
: undefined;
|
|
3297
3314
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
@@ -4059,7 +4076,7 @@ export class LayoutPass {
|
|
|
4059
4076
|
* stable within a pass.
|
|
4060
4077
|
*/
|
|
4061
4078
|
private viewThatFitsPlan(node: LayoutNode, ctx: Context, p: Proposal): { chosen: LayoutNode | null; size: Size } {
|
|
4062
|
-
return this.plan(node, p, () => {
|
|
4079
|
+
return this.plan(node, ctx, p, () => {
|
|
4063
4080
|
const axes = str(node.props['axes']) ?? 'both';
|
|
4064
4081
|
const checkWidth = axes !== 'vertical';
|
|
4065
4082
|
const checkHeight = axes !== 'horizontal';
|
|
@@ -4160,7 +4177,7 @@ export class LayoutPass {
|
|
|
4160
4177
|
* the track. Children are named by `labels` in order.
|
|
4161
4178
|
*/
|
|
4162
4179
|
private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
|
|
4163
|
-
return this.plan(node, p, () => {
|
|
4180
|
+
return this.plan(node, ctx, p, () => {
|
|
4164
4181
|
const width = finite(p.width) ? p.width : SLIDER_DEFAULT_WIDTH;
|
|
4165
4182
|
const cctx = this.childContext(node, ctx);
|
|
4166
4183
|
const names = Array.isArray(node.props['labels']) ? node.props['labels'].map(str) : [];
|
|
@@ -4223,7 +4240,7 @@ export class LayoutPass {
|
|
|
4223
4240
|
* trail. The width is the proposal, or label + 8 + capsules.
|
|
4224
4241
|
*/
|
|
4225
4242
|
private datePickerPlan(node: LayoutNode, ctx: Context, p: Proposal): DatePickerPlan {
|
|
4226
|
-
return this.plan(node, p, () => {
|
|
4243
|
+
return this.plan(node, ctx, p, () => {
|
|
4227
4244
|
const cctx = this.childContext(node, ctx);
|
|
4228
4245
|
const seconds = num(node.props['seconds']) ?? 0;
|
|
4229
4246
|
const components = str(node.props['components']) ?? 'dateAndTime';
|
|
@@ -4632,7 +4649,7 @@ export class LayoutPass {
|
|
|
4632
4649
|
* aligns its whole column.
|
|
4633
4650
|
*/
|
|
4634
4651
|
private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
|
|
4635
|
-
return this.plan(node, p, () => {
|
|
4652
|
+
return this.plan(node, ctx, p, () => {
|
|
4636
4653
|
const hSpacing = num(node.props['horizontalSpacing']) ?? DEFAULT_SPACING;
|
|
4637
4654
|
const vSpacing = num(node.props['verticalSpacing']) ?? DEFAULT_SPACING;
|
|
4638
4655
|
interface Source {
|
|
@@ -4799,7 +4816,7 @@ export class LayoutPass {
|
|
|
4799
4816
|
* its `rows` the lines along it (`y`, `height`: main axis).
|
|
4800
4817
|
*/
|
|
4801
4818
|
private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
|
|
4802
|
-
return this.plan(node, p, () => {
|
|
4819
|
+
return this.plan(node, ctx, p, () => {
|
|
4803
4820
|
const horizontal = node.kind === 'lazyhgrid';
|
|
4804
4821
|
const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
|
|
4805
4822
|
const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
|
|
@@ -4922,7 +4939,7 @@ export class LayoutPass {
|
|
|
4922
4939
|
* label in the gap at the bottom of its arc.
|
|
4923
4940
|
*/
|
|
4924
4941
|
private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
|
|
4925
|
-
return this.plan(node, p, () => {
|
|
4942
|
+
return this.plan(node, ctx, p, () => {
|
|
4926
4943
|
const width = finite(p.width) ? p.width : GAUGE_DEFAULT_WIDTH;
|
|
4927
4944
|
const cctx = this.childContext(node, ctx);
|
|
4928
4945
|
const labelCtx: Context = { ...cctx, font: resolveFont({ textStyle: 'caption' }, ctx.font, this.env) };
|
|
@@ -5022,7 +5039,7 @@ export class LayoutPass {
|
|
|
5022
5039
|
* measured at its ideal size and framed against the plot (src/layout/chart.ts).
|
|
5023
5040
|
*/
|
|
5024
5041
|
private chartPlan(node: LayoutNode, ctx: Context, p: Proposal): ChartPlan {
|
|
5025
|
-
return this.plan(node, p, () => {
|
|
5042
|
+
return this.plan(node, ctx, p, () => {
|
|
5026
5043
|
const size = chartSize(p.width, p.height);
|
|
5027
5044
|
// An infinite proposal reports infinite (the chart is flexible); the
|
|
5028
5045
|
// geometry is computed for the default size and recomputed when placed.
|
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';
|
|
@@ -363,6 +364,8 @@ export class Renderer {
|
|
|
363
364
|
private readonly resizeObserver: ResizeObserver | null = null;
|
|
364
365
|
/** `reportsPosition` scroll views mid-scroll: the timer that calls the scroll settled (src/scrollPosition.ts). */
|
|
365
366
|
private readonly positionTimers = new Map<HTMLElement, number>();
|
|
367
|
+
/** `reportsPosition` scroll views a finger is on: a scroll under it is not settled, however still the finger holds. */
|
|
368
|
+
private readonly heldScrolls = new Set<HTMLElement>();
|
|
366
369
|
/** The scroll target each `reportsPosition` scroll view last reported, so a settle at the same one says nothing. */
|
|
367
370
|
private readonly reportedPositions = new WeakMap<HTMLElement, number>();
|
|
368
371
|
/**
|
|
@@ -394,6 +397,7 @@ export class Renderer {
|
|
|
394
397
|
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
395
398
|
private readonly externalDrops: ExternalDrops;
|
|
396
399
|
private readonly pinches: PinchController;
|
|
400
|
+
private readonly touchPaging: TouchPaging;
|
|
397
401
|
/** `video` elements' media events, as `video` events (src/video.ts). */
|
|
398
402
|
private readonly videoEvents: VideoEvents;
|
|
399
403
|
private readonly measurer = new CanvasTextMeasurer();
|
|
@@ -862,6 +866,9 @@ export class Renderer {
|
|
|
862
866
|
root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
|
|
863
867
|
root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
|
|
864
868
|
root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
|
|
869
|
+
root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
|
|
870
|
+
root.addEventListener('touchend', this.onPositionTouchEnd, { capture: true, passive: true });
|
|
871
|
+
root.addEventListener('touchcancel', this.onPositionTouchEnd, { capture: true, passive: true });
|
|
865
872
|
this.videoEvents = new VideoEvents(root, (event) => this.onEvent(event));
|
|
866
873
|
// Phase 8: pointer gestures (the sheet drag among them) are delegated too.
|
|
867
874
|
const host: InteractionHost = {
|
|
@@ -879,6 +886,12 @@ export class Renderer {
|
|
|
879
886
|
this.externalDrops = new ExternalDrops(host);
|
|
880
887
|
// Two-finger gestures: MagnifyGesture and RotateGesture (src/pinch.ts).
|
|
881
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
|
+
});
|
|
882
895
|
root.addEventListener('change', this.onChange);
|
|
883
896
|
root.addEventListener('pointerdown', this.onSliderPointerDown);
|
|
884
897
|
if (typeof ResizeObserver !== 'undefined') {
|
|
@@ -1574,12 +1587,16 @@ export class Renderer {
|
|
|
1574
1587
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1575
1588
|
this.root.removeEventListener('scroll', this.onPositionScroll, true);
|
|
1576
1589
|
this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
|
|
1590
|
+
this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
|
|
1591
|
+
this.root.removeEventListener('touchend', this.onPositionTouchEnd, true);
|
|
1592
|
+
this.root.removeEventListener('touchcancel', this.onPositionTouchEnd, true);
|
|
1577
1593
|
for (const timer of this.positionTimers.values()) window.clearTimeout(timer);
|
|
1578
1594
|
this.positionTimers.clear();
|
|
1579
1595
|
this.interaction.dispose();
|
|
1580
1596
|
this.pressEffects.dispose();
|
|
1581
1597
|
this.externalDrops.dispose();
|
|
1582
1598
|
this.pinches.dispose();
|
|
1599
|
+
this.touchPaging.dispose();
|
|
1583
1600
|
this.videoEvents.dispose();
|
|
1584
1601
|
this.resizeObserver?.disconnect();
|
|
1585
1602
|
this.hostObserver?.disconnect();
|
|
@@ -1667,32 +1684,72 @@ export class Renderer {
|
|
|
1667
1684
|
// `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
|
|
1668
1685
|
// -------------------------------------------------------------------------
|
|
1669
1686
|
|
|
1670
|
-
/** A `reportsPosition` scroll view scrolled: it has settled when no further scroll follows for a moment. */
|
|
1687
|
+
/** A `reportsPosition` scroll view scrolled: it has settled when no further scroll follows for a moment (and no finger is on it). */
|
|
1671
1688
|
private readonly onPositionScroll = (event: Event): void => {
|
|
1672
1689
|
const el = event.target;
|
|
1673
1690
|
if (!(el instanceof HTMLElement) || el.dataset['sbReportsPosition'] === undefined) return;
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
this.positionTimers.set(
|
|
1677
|
-
el,
|
|
1678
|
-
window.setTimeout(() => this.reportPosition(el), POSITION_SETTLE_MS),
|
|
1679
|
-
);
|
|
1691
|
+
if (this.heldScrolls.has(el)) return;
|
|
1692
|
+
this.armPositionReport(el);
|
|
1680
1693
|
};
|
|
1681
1694
|
|
|
1682
1695
|
/** The browser says the scroll ended (Chrome, Firefox; Safari waits for the timer). */
|
|
1683
1696
|
private readonly onPositionScrollEnd = (event: Event): void => {
|
|
1684
1697
|
const el = event.target;
|
|
1685
1698
|
if (!(el instanceof HTMLElement) || el.dataset['sbReportsPosition'] === undefined) return;
|
|
1699
|
+
if (this.heldScrolls.has(el)) return;
|
|
1686
1700
|
this.reportPosition(el);
|
|
1687
1701
|
};
|
|
1688
1702
|
|
|
1703
|
+
/**
|
|
1704
|
+
* A finger came down inside `reportsPosition` scroll views: a pause in the
|
|
1705
|
+
* drag is not a settled scroll (the page would be reported, and an app
|
|
1706
|
+
* that re-centres on it would move the content under the finger). They
|
|
1707
|
+
* report once the finger lifts and the scroll (the snap after a fling)
|
|
1708
|
+
* settles.
|
|
1709
|
+
*/
|
|
1710
|
+
private readonly onPositionTouchStart = (event: Event): void => {
|
|
1711
|
+
const target = event.target;
|
|
1712
|
+
if (!(target instanceof Element)) return;
|
|
1713
|
+
for (let el = target.closest<HTMLElement>('[data-sb-reports-position]'); el; el = el.parentElement?.closest<HTMLElement>('[data-sb-reports-position]') ?? null) {
|
|
1714
|
+
this.heldScrolls.add(el);
|
|
1715
|
+
const pending = this.positionTimers.get(el);
|
|
1716
|
+
if (pending !== undefined) {
|
|
1717
|
+
window.clearTimeout(pending);
|
|
1718
|
+
this.positionTimers.delete(el);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
/** The last finger lifted: the held scroll views settle from here (one the finger paged, once its run rests). */
|
|
1724
|
+
private readonly onPositionTouchEnd = (event: Event): void => {
|
|
1725
|
+
if ((event as TouchEvent).touches?.length > 0) return;
|
|
1726
|
+
for (const el of this.heldScrolls) {
|
|
1727
|
+
if (this.touchPaging.drives(el)) continue;
|
|
1728
|
+
this.heldScrolls.delete(el);
|
|
1729
|
+
this.armPositionReport(el);
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
private armPositionReport(el: HTMLElement): void {
|
|
1734
|
+
const pending = this.positionTimers.get(el);
|
|
1735
|
+
if (pending !== undefined) window.clearTimeout(pending);
|
|
1736
|
+
this.positionTimers.set(
|
|
1737
|
+
el,
|
|
1738
|
+
window.setTimeout(() => this.reportPosition(el), POSITION_SETTLE_MS),
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1689
1742
|
/**
|
|
1690
1743
|
* Which scroll target of a settled scroll view is at its anchor point: the
|
|
1691
1744
|
* `scrollID` boxes inside it whose nearest scrolling ancestor it is, along
|
|
1692
1745
|
* the axis it scrolls on (the vertical one when it scrolls both ways). Sent
|
|
1693
|
-
* 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.
|
|
1694
1751
|
*/
|
|
1695
|
-
private reportPosition(el: HTMLElement): void {
|
|
1752
|
+
private reportPosition(el: HTMLElement, waited = 0): void {
|
|
1696
1753
|
const pending = this.positionTimers.get(el);
|
|
1697
1754
|
if (pending !== undefined) window.clearTimeout(pending);
|
|
1698
1755
|
this.positionTimers.delete(el);
|
|
@@ -1702,7 +1759,8 @@ export class Renderer {
|
|
|
1702
1759
|
const anchor = node.props['positionAnchor'] as { x?: unknown; y?: unknown } | undefined;
|
|
1703
1760
|
const fraction = horizontal ? anchor?.x : anchor?.y;
|
|
1704
1761
|
const box = el.getBoundingClientRect();
|
|
1705
|
-
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);
|
|
1706
1764
|
const candidates: PositionCandidate[] = [];
|
|
1707
1765
|
for (const target of el.querySelectorAll<HTMLElement>('[data-sb-id]')) {
|
|
1708
1766
|
const id = Number(target.dataset[ID_ATTR]);
|
|
@@ -1711,6 +1769,19 @@ export class Renderer {
|
|
|
1711
1769
|
const rect = target.getBoundingClientRect();
|
|
1712
1770
|
candidates.push(horizontal ? { id, start: rect.left, length: rect.width } : { id, start: rect.top, length: rect.height });
|
|
1713
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
|
+
}
|
|
1714
1785
|
const target = positionTarget(candidates, point);
|
|
1715
1786
|
if (target === null || this.reportedPositions.get(el) === target) return;
|
|
1716
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
|
+
}
|