@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 +1 -1
- package/src/layout/engine.ts +44 -27
- package/src/renderer.ts +48 -7
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
|
@@ -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
|
-
|
|
1675
|
-
|
|
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
|