@swiftbrowser/web 0.9.9 → 0.9.10

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.10",
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
@@ -363,6 +363,8 @@ export class Renderer {
363
363
  private readonly resizeObserver: ResizeObserver | null = null;
364
364
  /** `reportsPosition` scroll views mid-scroll: the timer that calls the scroll settled (src/scrollPosition.ts). */
365
365
  private readonly positionTimers = new Map<HTMLElement, number>();
366
+ /** `reportsPosition` scroll views a finger is on: a scroll under it is not settled, however still the finger holds. */
367
+ private readonly heldScrolls = new Set<HTMLElement>();
366
368
  /** The scroll target each `reportsPosition` scroll view last reported, so a settle at the same one says nothing. */
367
369
  private readonly reportedPositions = new WeakMap<HTMLElement, number>();
368
370
  /**
@@ -862,6 +864,9 @@ export class Renderer {
862
864
  root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
863
865
  root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
864
866
  root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
867
+ root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
868
+ root.addEventListener('touchend', this.onPositionTouchEnd, { capture: true, passive: true });
869
+ root.addEventListener('touchcancel', this.onPositionTouchEnd, { capture: true, passive: true });
865
870
  this.videoEvents = new VideoEvents(root, (event) => this.onEvent(event));
866
871
  // Phase 8: pointer gestures (the sheet drag among them) are delegated too.
867
872
  const host: InteractionHost = {
@@ -1574,6 +1579,9 @@ export class Renderer {
1574
1579
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1575
1580
  this.root.removeEventListener('scroll', this.onPositionScroll, true);
1576
1581
  this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
1582
+ this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
1583
+ this.root.removeEventListener('touchend', this.onPositionTouchEnd, true);
1584
+ this.root.removeEventListener('touchcancel', this.onPositionTouchEnd, true);
1577
1585
  for (const timer of this.positionTimers.values()) window.clearTimeout(timer);
1578
1586
  this.positionTimers.clear();
1579
1587
  this.interaction.dispose();
@@ -1667,25 +1675,58 @@ export class Renderer {
1667
1675
  // `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
1668
1676
  // -------------------------------------------------------------------------
1669
1677
 
1670
- /** A `reportsPosition` scroll view scrolled: it has settled when no further scroll follows for a moment. */
1678
+ /** A `reportsPosition` scroll view scrolled: it has settled when no further scroll follows for a moment (and no finger is on it). */
1671
1679
  private readonly onPositionScroll = (event: Event): void => {
1672
1680
  const el = event.target;
1673
1681
  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
- );
1682
+ if (this.heldScrolls.has(el)) return;
1683
+ this.armPositionReport(el);
1680
1684
  };
1681
1685
 
1682
1686
  /** The browser says the scroll ended (Chrome, Firefox; Safari waits for the timer). */
1683
1687
  private readonly onPositionScrollEnd = (event: Event): void => {
1684
1688
  const el = event.target;
1685
1689
  if (!(el instanceof HTMLElement) || el.dataset['sbReportsPosition'] === undefined) return;
1690
+ if (this.heldScrolls.has(el)) return;
1686
1691
  this.reportPosition(el);
1687
1692
  };
1688
1693
 
1694
+ /**
1695
+ * A finger came down inside `reportsPosition` scroll views: a pause in the
1696
+ * drag is not a settled scroll (the page would be reported, and an app
1697
+ * that re-centres on it would move the content under the finger). They
1698
+ * report once the finger lifts and the scroll (the snap after a fling)
1699
+ * settles.
1700
+ */
1701
+ private readonly onPositionTouchStart = (event: Event): void => {
1702
+ const target = event.target;
1703
+ if (!(target instanceof Element)) return;
1704
+ for (let el = target.closest<HTMLElement>('[data-sb-reports-position]'); el; el = el.parentElement?.closest<HTMLElement>('[data-sb-reports-position]') ?? null) {
1705
+ this.heldScrolls.add(el);
1706
+ const pending = this.positionTimers.get(el);
1707
+ if (pending !== undefined) {
1708
+ window.clearTimeout(pending);
1709
+ this.positionTimers.delete(el);
1710
+ }
1711
+ }
1712
+ };
1713
+
1714
+ /** The last finger lifted: the held scroll views settle from here. */
1715
+ private readonly onPositionTouchEnd = (event: Event): void => {
1716
+ if ((event as TouchEvent).touches?.length > 0) return;
1717
+ for (const el of this.heldScrolls) this.armPositionReport(el);
1718
+ this.heldScrolls.clear();
1719
+ };
1720
+
1721
+ private armPositionReport(el: HTMLElement): void {
1722
+ const pending = this.positionTimers.get(el);
1723
+ if (pending !== undefined) window.clearTimeout(pending);
1724
+ this.positionTimers.set(
1725
+ el,
1726
+ window.setTimeout(() => this.reportPosition(el), POSITION_SETTLE_MS),
1727
+ );
1728
+ }
1729
+
1689
1730
  /**
1690
1731
  * Which scroll target of a settled scroll view is at its anchor point: the
1691
1732
  * `scrollID` boxes inside it whose nearest scrolling ancestor it is, along