@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
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": {
@@ -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)` records the
7
- * frame and places the children using the same memoized plans. Context (font,
8
- * line limit, text alignment, enclosing stack axis, list row) flows down the
9
- * tree like SwiftUI's environment and is a pure function of the node's
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 = proposalKey(proposal);
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 === proposalKey(p) && last.x === x && last.y === y;
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 = proposalKey(p);
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
- if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
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
- const pending = this.positionTimers.get(el);
1675
- if (pending !== undefined) window.clearTimeout(pending);
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 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);
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);
@@ -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
+ }