@swiftbrowser/web 0.9.3 → 0.9.5
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/README.md +51 -1
- package/package.json +1 -1
- package/src/browserHistory.ts +268 -0
- package/src/deviceMode.ts +239 -0
- package/src/interaction.ts +10 -1
- package/src/layout/engine.ts +68 -20
- package/src/layout/measure.ts +24 -2
- package/src/layout/types.ts +10 -0
- package/src/layout/typography.ts +13 -3
- package/src/layoutDom.ts +31 -9
- package/src/main.ts +12 -1
- package/src/protocol.ts +15 -0
- package/src/renderer.ts +32 -1
- package/src/richText.ts +5 -2
- package/src/scrollAnchor.ts +110 -0
- package/src/styles.css +20 -4
package/src/layout/engine.ts
CHANGED
|
@@ -507,6 +507,17 @@ function flexibleFrameLength(proposed: number | null, child: number, min: number
|
|
|
507
507
|
return length;
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
/** Whether a root (through its `styled` wrappers) carries `ignoresKeyboard`: it keeps the keyboardless height. */
|
|
511
|
+
function ignoresKeyboard(node: LayoutNode): boolean {
|
|
512
|
+
let current = node;
|
|
513
|
+
while (current.kind === 'styled') {
|
|
514
|
+
if (styleOf(current)['ignoresKeyboard'] === true) return true;
|
|
515
|
+
if (current.children.length !== 1) return false;
|
|
516
|
+
current = current.children[0] as LayoutNode;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
510
521
|
/** The element under a chain of `styled` wrappers. */
|
|
511
522
|
function unwrapStyled(node: LayoutNode): LayoutNode {
|
|
512
523
|
let current = node;
|
|
@@ -1260,6 +1271,12 @@ export class LayoutPass {
|
|
|
1260
1271
|
};
|
|
1261
1272
|
}
|
|
1262
1273
|
|
|
1274
|
+
/** The on-screen keyboard's height over the screen's bottom (device mode, a field being edited); 0 while it is down. */
|
|
1275
|
+
private get keyboard(): number {
|
|
1276
|
+
const keyboard = this.env.keyboard;
|
|
1277
|
+
return typeof keyboard === 'number' && Number.isFinite(keyboard) && keyboard > 0 ? keyboard : 0;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1263
1280
|
run(roots: readonly LayoutNode[]): LayoutResult {
|
|
1264
1281
|
this.runId += 1;
|
|
1265
1282
|
this.touched = new Set();
|
|
@@ -1285,9 +1302,12 @@ export class LayoutPass {
|
|
|
1285
1302
|
const size = this.measure(root, ctx, proposal);
|
|
1286
1303
|
this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
|
|
1287
1304
|
} else {
|
|
1305
|
+
// The keyboard (device mode) is a bottom inset too, unless the root
|
|
1306
|
+
// ignores it (`.ignoresSafeArea(.keyboard)`: the screen keeps its height).
|
|
1307
|
+
const keyboard = ignoresKeyboard(root) ? 0 : this.keyboard;
|
|
1288
1308
|
const safe: Size = {
|
|
1289
1309
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
1290
|
-
height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
|
|
1310
|
+
height: Math.max(0, screen.height - safeArea.top - Math.max(safeArea.bottom, keyboard)),
|
|
1291
1311
|
};
|
|
1292
1312
|
if (this.env.contentHeight) {
|
|
1293
1313
|
// An island: the root takes its content's height and sits at the top.
|
|
@@ -3591,7 +3611,9 @@ export class LayoutPass {
|
|
|
3591
3611
|
this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
|
|
3592
3612
|
}
|
|
3593
3613
|
|
|
3594
|
-
// The inline title: centered, no wider than 56% of the
|
|
3614
|
+
// The inline title: centered, in a compact width no wider than 56% of the
|
|
3615
|
+
// bar (as UIKit keeps it on a phone; a wide bar on an iPad or in a split
|
|
3616
|
+
// view's column gives a title all the room its items leave), and clear of
|
|
3595
3617
|
// whatever the back button and the bar items occupy on either side. Its
|
|
3596
3618
|
// width is rounded up to whole pixels: snapping floors both edges, which
|
|
3597
3619
|
// takes up to a pixel off a fractional width at a fractional origin, and
|
|
@@ -3615,9 +3637,10 @@ export class LayoutPass {
|
|
|
3615
3637
|
// each side), and truncated only when it does not fit even there.
|
|
3616
3638
|
const roomStart = x + leftUsed + 8;
|
|
3617
3639
|
const roomEnd = x + size.width - rightUsed - 8;
|
|
3640
|
+
const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
|
|
3618
3641
|
const titleWidth = Math.min(
|
|
3619
3642
|
Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001),
|
|
3620
|
-
|
|
3643
|
+
compactCap,
|
|
3621
3644
|
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
3622
3645
|
);
|
|
3623
3646
|
const centered = x + (size.width - titleWidth) / 2;
|
|
@@ -3651,8 +3674,13 @@ export class LayoutPass {
|
|
|
3651
3674
|
const bottomCover = Math.max(bottomBarHeight, searchBottom);
|
|
3652
3675
|
// Under a tab bar the bar (which includes the bottom safe area) is what
|
|
3653
3676
|
// non-scrolling content must clear; a bottom toolbar already excluded it.
|
|
3654
|
-
const
|
|
3655
|
-
|
|
3677
|
+
const clears = bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
3678
|
+
// The on-screen keyboard (device mode) covers the screen's bottom, the
|
|
3679
|
+
// bars with it: the content stops above it as on iOS (the bars stay
|
|
3680
|
+
// put, covered), and a scroll view runs under only the part of a bar
|
|
3681
|
+
// still showing above it.
|
|
3682
|
+
const keyboard = reachesBottom ? this.keyboard : 0;
|
|
3683
|
+
const bottomInset = Math.max(clears, keyboard - bottomCover);
|
|
3656
3684
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
3657
3685
|
// large title / search field), which is transparent over it.
|
|
3658
3686
|
const contentCtx: Context = {
|
|
@@ -3663,6 +3691,10 @@ export class LayoutPass {
|
|
|
3663
3691
|
barAbove: undefined,
|
|
3664
3692
|
...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
|
|
3665
3693
|
};
|
|
3694
|
+
if (keyboard > 0) {
|
|
3695
|
+
contentCtx.bottomBar = Math.max(0, (bottomCover > 0 ? bottomCover : cctx.bottomBar) - keyboard);
|
|
3696
|
+
contentCtx.bottomEdge = y + size.height - keyboard;
|
|
3697
|
+
}
|
|
3666
3698
|
if (scroller) {
|
|
3667
3699
|
// iOS 26: the scroll view starts at the top of the screen and its
|
|
3668
3700
|
// content scrolls under the bar (and the large title moves with it);
|
|
@@ -3674,7 +3706,7 @@ export class LayoutPass {
|
|
|
3674
3706
|
largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
|
|
3675
3707
|
search: searchRow,
|
|
3676
3708
|
});
|
|
3677
|
-
this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
|
|
3709
|
+
this.place(child, contentCtx, { width: content.width, height: Math.max(0, content.height + top + bottomCover - keyboard) }, content.x, y);
|
|
3678
3710
|
return;
|
|
3679
3711
|
}
|
|
3680
3712
|
if (direct) this.scrollInsets.delete(direct);
|
|
@@ -3683,7 +3715,11 @@ export class LayoutPass {
|
|
|
3683
3715
|
// One that reaches the bottom of the content runs down under all the
|
|
3684
3716
|
// content clears there.
|
|
3685
3717
|
if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
|
|
3686
|
-
if (
|
|
3718
|
+
if (keyboard > 0) {
|
|
3719
|
+
contentCtx.bottomBar = Math.max(0, (bottomCover > 0 ? bottomCover + clears : cctx.bottomBar) - keyboard);
|
|
3720
|
+
} else if (bottomCover > 0) {
|
|
3721
|
+
contentCtx.bottomBar = bottomCover + clears;
|
|
3722
|
+
}
|
|
3687
3723
|
if (direct && direct.kind === 'scrollview') {
|
|
3688
3724
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
3689
3725
|
return;
|
|
@@ -3872,13 +3908,16 @@ export class LayoutPass {
|
|
|
3872
3908
|
* centered between the status bar and the tab bar.
|
|
3873
3909
|
*/
|
|
3874
3910
|
private placeTabContent(tab: LayoutNode, cctx: Context, x: number, y: number, size: Size, barHeight: number): void {
|
|
3875
|
-
const { safeArea } = this.env;
|
|
3911
|
+
const { safeArea, screen } = this.env;
|
|
3876
3912
|
const topInset = y <= 0 ? safeArea.top : 0;
|
|
3913
|
+
// The keyboard (device mode) covers the tab bar: the content stops above
|
|
3914
|
+
// whichever is taller (a `navstack` handles the keyboard in its screens).
|
|
3915
|
+
const keyboard = y + size.height >= screen.height - EPSILON ? this.keyboard : 0;
|
|
3877
3916
|
const area: Rect = {
|
|
3878
3917
|
x,
|
|
3879
3918
|
y: y + topInset,
|
|
3880
3919
|
width: size.width,
|
|
3881
|
-
height: Math.max(0, size.height - topInset - barHeight),
|
|
3920
|
+
height: Math.max(0, size.height - topInset - Math.max(barHeight, keyboard)),
|
|
3882
3921
|
};
|
|
3883
3922
|
this.setChrome(tab, { content: area });
|
|
3884
3923
|
const child = this.contentOf(tab);
|
|
@@ -3889,8 +3928,9 @@ export class LayoutPass {
|
|
|
3889
3928
|
return;
|
|
3890
3929
|
}
|
|
3891
3930
|
if (kind === 'list' || kind === 'scrollview') {
|
|
3892
|
-
const height = Math.max(0, size.height - topInset);
|
|
3893
|
-
|
|
3931
|
+
const height = Math.max(0, size.height - topInset - keyboard);
|
|
3932
|
+
const ctx: Context = keyboard > 0 ? { ...cctx, bottomBar: Math.max(0, barHeight - keyboard), bottomEdge: y + size.height - keyboard } : cctx;
|
|
3933
|
+
this.place(child, ctx, { width: size.width, height }, x, y + topInset);
|
|
3894
3934
|
return;
|
|
3895
3935
|
}
|
|
3896
3936
|
const proposal: Proposal = { width: area.width, height: area.height };
|
|
@@ -4044,20 +4084,23 @@ export class LayoutPass {
|
|
|
4044
4084
|
* box touches (or is inside) that safe-area inset band; null when nothing grows.
|
|
4045
4085
|
*/
|
|
4046
4086
|
private safeAreaExtension(node: LayoutNode, ctx: Context, x: number, y: number, size: Size): EdgeInsets | null {
|
|
4047
|
-
const
|
|
4048
|
-
|
|
4087
|
+
const style = styleOf(node);
|
|
4088
|
+
const raw = style['ignoresSafeArea'];
|
|
4049
4089
|
const edges: string[] = raw === 'all' ? [...SAFE_AREA_EDGES] : Array.isArray(raw) ? raw.map(str).filter((e): e is string => e !== null) : [];
|
|
4050
|
-
|
|
4090
|
+
// `.ignoresSafeArea(.keyboard)`: the box runs down under the keyboard
|
|
4091
|
+
// the content above it stopped at (device mode, while it is up).
|
|
4092
|
+
const keyboard = style['ignoresKeyboard'] === true ? this.keyboard : 0;
|
|
4093
|
+
if (edges.length === 0 && keyboard === 0) return null;
|
|
4051
4094
|
const { screen, safeArea } = this.env;
|
|
4052
4095
|
// Inside a `navscreen` the bar is what the content clears (`ctx.safeTop`);
|
|
4053
4096
|
// under a tab bar, the bar (`ctx.bottomBar`, which includes the bottom
|
|
4054
4097
|
// safe area).
|
|
4055
4098
|
const top = ctx.safeTop;
|
|
4056
|
-
const bottom = Math.max(safeArea.bottom, ctx.bottomBar);
|
|
4099
|
+
const bottom = Math.max(edges.includes('bottom') ? Math.max(safeArea.bottom, ctx.bottomBar) : 0, keyboard);
|
|
4057
4100
|
const ext: EdgeInsets = { top: 0, leading: 0, bottom: 0, trailing: 0 };
|
|
4058
4101
|
if (edges.includes('top') && top > 0 && y <= top + EPSILON) ext.top = Math.max(0, y);
|
|
4059
4102
|
if (edges.includes('leading') && safeArea.leading > 0 && x <= safeArea.leading + EPSILON) ext.leading = Math.max(0, x);
|
|
4060
|
-
if (
|
|
4103
|
+
if (bottom > 0 && y + size.height >= screen.height - bottom - EPSILON) {
|
|
4061
4104
|
ext.bottom = Math.max(0, screen.height - (y + size.height));
|
|
4062
4105
|
}
|
|
4063
4106
|
if (edges.includes('trailing') && safeArea.trailing > 0 && x + size.width >= screen.width - safeArea.trailing - EPSILON) {
|
|
@@ -4451,7 +4494,10 @@ export class LayoutPass {
|
|
|
4451
4494
|
this.setChrome(node, chrome);
|
|
4452
4495
|
const child = this.contentOf(node);
|
|
4453
4496
|
if (!child) return;
|
|
4454
|
-
|
|
4497
|
+
// The keyboard (device mode) covers the sheet's bottom too: a list, a
|
|
4498
|
+
// scroll view or plain content stops above it (a navigation stack's
|
|
4499
|
+
// screens handle it themselves).
|
|
4500
|
+
const covered = Math.max(0, y + size.height - (this.env.screen.height - this.keyboard));
|
|
4455
4501
|
const kind = unwrapStyled(child).kind;
|
|
4456
4502
|
if (kind === 'navstack') {
|
|
4457
4503
|
this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
|
|
@@ -4460,12 +4506,14 @@ export class LayoutPass {
|
|
|
4460
4506
|
if (kind === 'list' || kind === 'scrollview') {
|
|
4461
4507
|
// A scrolling view fills the sheet below the grabber row, if any.
|
|
4462
4508
|
const top = grabber ? SHEET_GRABBER_ROW : 0;
|
|
4463
|
-
this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
|
|
4509
|
+
this.place(child, cctx, { width: size.width, height: Math.max(0, size.height - top - covered) }, x, y + top);
|
|
4464
4510
|
return;
|
|
4465
4511
|
}
|
|
4512
|
+
const area: Rect = { ...content, height: Math.max(0, content.height - covered) };
|
|
4513
|
+
const proposal: Proposal = { width: area.width, height: area.height };
|
|
4466
4514
|
const childSize = this.measure(child, cctx, proposal);
|
|
4467
|
-
const { dx, dy } = alignIn(
|
|
4468
|
-
this.place(child, cctx, proposal,
|
|
4515
|
+
const { dx, dy } = alignIn(area, childSize, 'center');
|
|
4516
|
+
this.place(child, cctx, proposal, area.x + dx, area.y + dy);
|
|
4469
4517
|
}
|
|
4470
4518
|
|
|
4471
4519
|
// -------------------------------------------------------------------------
|
package/src/layout/measure.ts
CHANGED
|
@@ -30,7 +30,7 @@ export class CanvasTextMeasurer implements TextMeasurer {
|
|
|
30
30
|
|
|
31
31
|
measureWidth(text: string, font: ResolvedFont): number {
|
|
32
32
|
if (text.length === 0) return 0;
|
|
33
|
-
const key = `${font.css}\u0000${text}`;
|
|
33
|
+
const key = `${font.css}\u0000${font.monospacedDigit ? 'T' : ''}\u0000${text}`;
|
|
34
34
|
const cached = this.cache.get(key);
|
|
35
35
|
if (cached !== undefined) return cached;
|
|
36
36
|
const context = this.acquireContext();
|
|
@@ -41,7 +41,9 @@ export class CanvasTextMeasurer implements TextMeasurer {
|
|
|
41
41
|
context.font = css;
|
|
42
42
|
this.currentFont = css;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
// A canvas has no `font-variant-numeric`: tabular digits are measured
|
|
45
|
+
// as the font's widest digit, which is the width a tabular figure takes.
|
|
46
|
+
width = context.measureText(font.monospacedDigit ? text.replace(/[0-9]/g, this.widestDigit(context, css)) : text).width;
|
|
45
47
|
} else {
|
|
46
48
|
width = this.fallback.measureWidth(text, font);
|
|
47
49
|
}
|
|
@@ -50,9 +52,29 @@ export class CanvasTextMeasurer implements TextMeasurer {
|
|
|
50
52
|
return width;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
private readonly widestDigits = new Map<string, string>();
|
|
56
|
+
|
|
57
|
+
/** The font's widest digit (its `font` already set on `context`). */
|
|
58
|
+
private widestDigit(context: Canvas2D, css: string): string {
|
|
59
|
+
const known = this.widestDigits.get(css);
|
|
60
|
+
if (known !== undefined) return known;
|
|
61
|
+
let widest = '0';
|
|
62
|
+
let max = -1;
|
|
63
|
+
for (const digit of '0123456789') {
|
|
64
|
+
const width = context.measureText(digit).width;
|
|
65
|
+
if (width > max) {
|
|
66
|
+
max = width;
|
|
67
|
+
widest = digit;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.widestDigits.set(css, widest);
|
|
71
|
+
return widest;
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
/** Drop cached widths (call when web fonts finish loading). */
|
|
54
75
|
clearCache(): void {
|
|
55
76
|
this.cache.clear();
|
|
77
|
+
this.widestDigits.clear();
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
private acquireContext(): Canvas2D | null {
|
package/src/layout/types.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface ResolvedFont {
|
|
|
76
76
|
*/
|
|
77
77
|
weightModifier?: number;
|
|
78
78
|
familyModifier?: FontFamily;
|
|
79
|
+
/** `.monospacedDigit()`: digits at one width, drawn as `tabular-nums` and measured as the font's widest digit. */
|
|
80
|
+
monospacedDigit?: boolean;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export interface TextMeasurer {
|
|
@@ -108,6 +110,14 @@ export interface LayoutEnvironment {
|
|
|
108
110
|
* navigation and tab roots still fill the screen.
|
|
109
111
|
*/
|
|
110
112
|
contentHeight?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The height of the on-screen keyboard covering the screen's bottom (device
|
|
115
|
+
* mode, while a field is being edited), 0 or absent when it is down. As on
|
|
116
|
+
* iOS it is a bottom safe-area inset: content stops above it (a bar under
|
|
117
|
+
* it stays put, covered), and a `styled` box with `ignoresKeyboard` runs
|
|
118
|
+
* under it.
|
|
119
|
+
*/
|
|
120
|
+
keyboard?: number;
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
export type TextAlignment = 'leading' | 'center' | 'trailing';
|
package/src/layout/typography.ts
CHANGED
|
@@ -155,13 +155,14 @@ export function makeFont(
|
|
|
155
155
|
family: FontFamily,
|
|
156
156
|
size: number,
|
|
157
157
|
weight: number,
|
|
158
|
-
modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
|
|
158
|
+
modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined; monospacedDigit?: boolean | undefined } = {},
|
|
159
159
|
): ResolvedFont {
|
|
160
160
|
const lineHeight = lineHeightFor(size);
|
|
161
161
|
const css = `${weight} ${formatPx(size)}/${formatPx(lineHeight)} ${FONT_FAMILIES[family]}`;
|
|
162
162
|
const font: ResolvedFont = { family, size, weight, lineHeight, leading: leadingFor(size), css };
|
|
163
163
|
if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
|
|
164
164
|
if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
|
|
165
|
+
if (modifiers.monospacedDigit) font.monospacedDigit = true;
|
|
165
166
|
return font;
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -218,8 +219,17 @@ export function resolveFont(
|
|
|
218
219
|
// A weight or design without a new font is a modifier the text further in keeps.
|
|
219
220
|
const weightModifier = !isNewFont && explicitWeight !== undefined ? explicitWeight : inherited.weightModifier;
|
|
220
221
|
const familyModifier = !isNewFont && explicitFamily !== undefined ? explicitFamily : inherited.familyModifier;
|
|
221
|
-
|
|
222
|
+
// Tabular digits stay on until a font says otherwise, like a weight modifier.
|
|
223
|
+
const monospacedDigit = typeof font['monospacedDigit'] === 'boolean' ? font['monospacedDigit'] : inherited.monospacedDigit === true;
|
|
224
|
+
if (
|
|
225
|
+
!isNewFont &&
|
|
226
|
+
weight === inherited.weight &&
|
|
227
|
+
family === inherited.family &&
|
|
228
|
+
weightModifier === inherited.weightModifier &&
|
|
229
|
+
familyModifier === inherited.familyModifier &&
|
|
230
|
+
monospacedDigit === (inherited.monospacedDigit === true)
|
|
231
|
+
) {
|
|
222
232
|
return inherited;
|
|
223
233
|
}
|
|
224
|
-
return makeFont(family, size, weight, { weightModifier, familyModifier });
|
|
234
|
+
return makeFont(family, size, weight, { weightModifier, familyModifier, monospacedDigit });
|
|
225
235
|
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -118,15 +118,8 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
118
118
|
ids = result.frames.keys();
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const el = ctx.elements.get(id);
|
|
124
|
-
const node = ctx.nodes.get(id);
|
|
125
|
-
if (!el || !node) continue;
|
|
126
|
-
if (!frame) {
|
|
127
|
-
boxes.delete(id);
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
121
|
+
/** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
|
|
122
|
+
const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
|
|
130
123
|
const box = drawnBox(node, boxOf(ctx, result, node, frame));
|
|
131
124
|
const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : isSectionSupplement(node) ? supplementOrigin(ctx, result, id) : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
|
|
132
125
|
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
@@ -138,11 +131,37 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
138
131
|
if (node.kind === 'sheet') positionSheetCard(el, box);
|
|
139
132
|
else positionBox(el, box, origin);
|
|
140
133
|
}
|
|
134
|
+
return { box, moved, previous };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const visited = result.changed ? new Set(ids) : null;
|
|
138
|
+
for (const id of ids) {
|
|
139
|
+
const frame = result.frames.get(id);
|
|
140
|
+
const el = ctx.elements.get(id);
|
|
141
|
+
const node = ctx.nodes.get(id);
|
|
142
|
+
if (!el || !node) continue;
|
|
143
|
+
if (!frame) {
|
|
144
|
+
boxes.delete(id);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const { box, moved, previous } = applyBox(id, node, el, frame);
|
|
141
148
|
// A former menu item that is laid out again (moved out of the menu), or a
|
|
142
149
|
// `viewthatfits` candidate chosen again, shows again.
|
|
143
150
|
if (el.hidden) el.hidden = false;
|
|
144
151
|
applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
|
|
145
152
|
if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
|
|
153
|
+
// A child the engine did not touch kept its screen frame, but if this
|
|
154
|
+
// element's box moved, the child's `left`/`top` (relative to it) did
|
|
155
|
+
// not: the plus button at the bottom of a chat's composer stays put on
|
|
156
|
+
// screen while the composer grows upwards around it. Re-place such
|
|
157
|
+
// children; their own subtrees are relative to them and unchanged.
|
|
158
|
+
if (!moved || !visited) continue;
|
|
159
|
+
for (const child of node.children) {
|
|
160
|
+
if (visited.has(child.id)) continue;
|
|
161
|
+
const childFrame = result.frames.get(child.id);
|
|
162
|
+
const childEl = ctx.elements.get(child.id);
|
|
163
|
+
if (childFrame && childEl) applyBox(child.id, child, childEl, childFrame);
|
|
164
|
+
}
|
|
146
165
|
}
|
|
147
166
|
|
|
148
167
|
// Phase 6: menu items are hidden without a frame (tabs keep theirs, above);
|
|
@@ -414,6 +433,9 @@ function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): vo
|
|
|
414
433
|
if (!font) return;
|
|
415
434
|
const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
|
|
416
435
|
if (el.style.font !== css) el.style.font = css;
|
|
436
|
+
// The `font` shorthand resets the variant, so it goes after it.
|
|
437
|
+
const variant = font.monospacedDigit ? 'tabular-nums' : '';
|
|
438
|
+
if (el.style.fontVariantNumeric !== variant) el.style.fontVariantNumeric = variant;
|
|
417
439
|
}
|
|
418
440
|
|
|
419
441
|
const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
|
package/src/main.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { easingFor, springEasing, timingFor } from './animation';
|
|
3
3
|
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
|
|
4
4
|
import { ChromeColor } from './chromeColor';
|
|
5
|
-
import { applyPageMode, decidePageMode, readViewportInsets, watchViewport, type PageMode } from './deviceMode';
|
|
5
|
+
import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
|
|
6
6
|
import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
|
|
7
7
|
import { createMockApp } from './mock';
|
|
8
8
|
import {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type RendererEvent,
|
|
15
15
|
type ScenePhase,
|
|
16
16
|
} from './protocol';
|
|
17
|
+
import { BrowserHistory } from './browserHistory';
|
|
17
18
|
import { Renderer, RendererError } from './renderer';
|
|
18
19
|
import { withBlockedHosts } from './blockedHosts';
|
|
19
20
|
import { withCORSProxy } from './corsProxy';
|
|
@@ -97,6 +98,9 @@ function dispatch(event: RendererEvent): void {
|
|
|
97
98
|
|
|
98
99
|
const renderer = new Renderer(screen, dispatch);
|
|
99
100
|
|
|
101
|
+
// The browser's back pops the app's screens and dismisses its sheets (browserHistory.ts).
|
|
102
|
+
new BrowserHistory(renderer, screen).enable();
|
|
103
|
+
|
|
100
104
|
// On a phone the browser's status bar strip and safe areas take the color the
|
|
101
105
|
// app draws at its top edge (chromeColor.ts).
|
|
102
106
|
const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
|
|
@@ -246,6 +250,13 @@ if (deviceMode) {
|
|
|
246
250
|
// A rotation or window resize may cross the compact / regular threshold.
|
|
247
251
|
if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
|
|
248
252
|
});
|
|
253
|
+
// The on-screen keyboard does not shrink `#screen` (the layout viewport
|
|
254
|
+
// keeps its height); it covers the screen's bottom. The engine gets its
|
|
255
|
+
// height as a bottom inset, so a field or a chat's composer rises above
|
|
256
|
+
// it and a scroll view keeps its end in view, as on iOS.
|
|
257
|
+
// `?debug=viewport` shows the numbers the measurement reads, over the app.
|
|
258
|
+
const readout = new URLSearchParams(location.search).get('debug') === 'viewport' ? installViewportReadout() : undefined;
|
|
259
|
+
watchKeyboard((height) => renderer.setKeyboard(height), { root: device, onRead: readout });
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
// ---------------------------------------------------------------------------
|
package/src/protocol.ts
CHANGED
|
@@ -223,6 +223,8 @@ export interface Font {
|
|
|
223
223
|
relativeTo?: TextStyle;
|
|
224
224
|
weight?: FontWeight;
|
|
225
225
|
design?: FontDesign;
|
|
226
|
+
/** `.monospacedDigit()`: digits of one width (`font-variant-numeric: tabular-nums`). */
|
|
227
|
+
monospacedDigit?: boolean;
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
export type FrameAlignment =
|
|
@@ -2140,6 +2142,13 @@ export interface Style {
|
|
|
2140
2142
|
* child's proposal and position are unchanged (the extension is background).
|
|
2141
2143
|
*/
|
|
2142
2144
|
ignoresSafeArea?: 'all' | Edge[];
|
|
2145
|
+
/**
|
|
2146
|
+
* `.ignoresSafeArea(.keyboard)` (and `.all`, the default): while the
|
|
2147
|
+
* on-screen keyboard is up (device mode), the engine extends the box down
|
|
2148
|
+
* under it instead of stopping above it, and a root that carries it keeps
|
|
2149
|
+
* the keyboardless height.
|
|
2150
|
+
*/
|
|
2151
|
+
ignoresKeyboard?: boolean;
|
|
2143
2152
|
}
|
|
2144
2153
|
|
|
2145
2154
|
/** `.repeatCount(_:autoreverses:)` / `.repeatForever(autoreverses:)`. */
|
|
@@ -2197,6 +2206,12 @@ export interface ScrollViewProps {
|
|
|
2197
2206
|
dismissesKeyboard?: boolean;
|
|
2198
2207
|
/** As for `list`. */
|
|
2199
2208
|
edgeEffect?: EdgeEffectProps;
|
|
2209
|
+
/**
|
|
2210
|
+
* `.defaultScrollAnchor(_:)`: the content starts with this point of it at
|
|
2211
|
+
* the same point of the viewport (`{x: 0.5, y: 1}`: the end in view), and
|
|
2212
|
+
* keeps it when the content grows while the viewer is at it (src/scrollAnchor.ts).
|
|
2213
|
+
*/
|
|
2214
|
+
defaultScrollAnchor?: UnitPoint;
|
|
2200
2215
|
}
|
|
2201
2216
|
|
|
2202
2217
|
/**
|
package/src/renderer.ts
CHANGED
|
@@ -151,6 +151,7 @@ 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 { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
|
|
154
155
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
155
156
|
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
156
157
|
import { hostContent, onHostContentChange } from './hostContent';
|
|
@@ -406,6 +407,8 @@ export class Renderer {
|
|
|
406
407
|
private assetStyle: HTMLStyleElement | null = null;
|
|
407
408
|
/** Safe-area insets; read from the device tokens on first use unless `setSafeArea` set them. */
|
|
408
409
|
private safeArea: LayoutEnvironment['safeArea'] | null = null;
|
|
410
|
+
/** Device mode: the on-screen keyboard's height over the screen's bottom (`setKeyboard`), 0 while it is down. */
|
|
411
|
+
private keyboard = 0;
|
|
409
412
|
/** DOM boxes applied by the last layout, by id (see layoutDom.ts). */
|
|
410
413
|
private boxes = new Map<ElementId, Rect>();
|
|
411
414
|
/** Elements whose inline style was reset since the last layout. */
|
|
@@ -1208,6 +1211,19 @@ export class Renderer {
|
|
|
1208
1211
|
if (this.commits > 0) this.layout();
|
|
1209
1212
|
}
|
|
1210
1213
|
|
|
1214
|
+
/**
|
|
1215
|
+
* Device mode: the on-screen keyboard covers `height` CSS px of the
|
|
1216
|
+
* screen's bottom (0: it is down). The engine treats it as a bottom
|
|
1217
|
+
* safe-area inset, as iOS does: content stops above it, a bar under it
|
|
1218
|
+
* stays put. Re-lays the tree out when it changed.
|
|
1219
|
+
*/
|
|
1220
|
+
setKeyboard(height: number): void {
|
|
1221
|
+
const next = Number.isFinite(height) && height > 0 ? Math.round(height) : 0;
|
|
1222
|
+
if (next === this.keyboard) return;
|
|
1223
|
+
this.keyboard = next;
|
|
1224
|
+
if (this.commits > 0) this.layout();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1211
1227
|
/**
|
|
1212
1228
|
* The root's size changed (device mode: a resize or rotation): re-read it
|
|
1213
1229
|
* and re-lay out now rather than when the ResizeObserver reports it.
|
|
@@ -1229,6 +1245,7 @@ export class Renderer {
|
|
|
1229
1245
|
colorScheme: this.environment.colorScheme,
|
|
1230
1246
|
assets: this.assets,
|
|
1231
1247
|
...(this.contentHeight ? { contentHeight: true } : {}),
|
|
1248
|
+
...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
|
|
1232
1249
|
};
|
|
1233
1250
|
}
|
|
1234
1251
|
|
|
@@ -1242,6 +1259,15 @@ export class Renderer {
|
|
|
1242
1259
|
/** Called after each layout pass (main.ts keeps the browser chrome's color in step). */
|
|
1243
1260
|
afterLayout: (() => void) | null = null;
|
|
1244
1261
|
|
|
1262
|
+
/**
|
|
1263
|
+
* Sends a tap for an element, as a click on it would: a `navscreen` pops
|
|
1264
|
+
* it (the back button's tap), a `sheet` asks to be dismissed (the scrim's).
|
|
1265
|
+
* For the page's own controls, such as the browser's history (browserHistory.ts).
|
|
1266
|
+
*/
|
|
1267
|
+
tap(id: ElementId): void {
|
|
1268
|
+
this.onEvent({ type: 'tap', id });
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1245
1271
|
/**
|
|
1246
1272
|
* Run the layout engine over the current tree and position every element.
|
|
1247
1273
|
* Returns the elements whose box changed. Runs at every `commit`; call it
|
|
@@ -1275,6 +1301,8 @@ export class Renderer {
|
|
|
1275
1301
|
};
|
|
1276
1302
|
const changes = applyLayout(ctx, result);
|
|
1277
1303
|
this.boxes = ctx.boxes;
|
|
1304
|
+
// `.defaultScrollAnchor`: scroll views that keep their end (or another point) in view as they grow.
|
|
1305
|
+
keepScrollAnchors();
|
|
1278
1306
|
if (this.contentHeight) {
|
|
1279
1307
|
// The island's root is as tall as its content.
|
|
1280
1308
|
let bottom = 0;
|
|
@@ -3105,6 +3133,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3105
3133
|
else delete el.dataset['sbSnap'];
|
|
3106
3134
|
if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
3107
3135
|
applyEdgeEffect(el, scroll.edgeEffect);
|
|
3136
|
+
applyDefaultScrollAnchor(el, scroll.defaultScrollAnchor);
|
|
3108
3137
|
break;
|
|
3109
3138
|
}
|
|
3110
3139
|
case 'section':
|
|
@@ -3817,7 +3846,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3817
3846
|
}
|
|
3818
3847
|
|
|
3819
3848
|
if (style.font) {
|
|
3820
|
-
const { size, weight, design, textStyle, relativeTo } = style.font;
|
|
3849
|
+
const { size, weight, design, textStyle, relativeTo, monospacedDigit } = style.font;
|
|
3821
3850
|
if (size != null) {
|
|
3822
3851
|
// A fixed size; `relativeTo` scales it like that text style (the
|
|
3823
3852
|
// scale factor is a CSS variable the page sets per Dynamic Type size).
|
|
@@ -3840,6 +3869,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3840
3869
|
else if (design === 'rounded') s.fontFamily = FONT_FAMILY_ROUNDED;
|
|
3841
3870
|
else if (design === 'serif') s.fontFamily = FONT_FAMILY_SERIF;
|
|
3842
3871
|
else if (design === 'default') s.fontFamily = 'var(--sb-font-family)';
|
|
3872
|
+
// Inherited by the DOM inside; the engine's text fonts carry it too (setFont).
|
|
3873
|
+
if (monospacedDigit !== undefined) s.fontVariantNumeric = monospacedDigit ? 'tabular-nums' : 'normal';
|
|
3843
3874
|
}
|
|
3844
3875
|
|
|
3845
3876
|
if (style.foreground) {
|
package/src/richText.ts
CHANGED
|
@@ -20,7 +20,7 @@ const drawn = new WeakMap<HTMLElement, { templates: HTMLElement[]; key: string }
|
|
|
20
20
|
/** Draws the engine's lines of a rich text into its element. */
|
|
21
21
|
export function drawRichText(el: HTMLElement, rich: RichTextLayout): void {
|
|
22
22
|
const templates = runTemplates.get(el) ?? [];
|
|
23
|
-
const key = JSON.stringify([rich.lines, rich.heights, rich.fonts.map((font) => font.css)]);
|
|
23
|
+
const key = JSON.stringify([rich.lines, rich.heights, rich.fonts.map((font) => [font.css, font.monospacedDigit === true])]);
|
|
24
24
|
const last = drawn.get(el);
|
|
25
25
|
if (last && last.templates === templates && last.key === key) return;
|
|
26
26
|
drawn.set(el, { templates, key });
|
|
@@ -48,7 +48,10 @@ export function drawRichText(el: HTMLElement, rich: RichTextLayout): void {
|
|
|
48
48
|
}
|
|
49
49
|
const span = (template?.cloneNode(false) as HTMLElement | undefined) ?? doc.createElement('span');
|
|
50
50
|
const font = rich.fonts[fragment.run];
|
|
51
|
-
if (font)
|
|
51
|
+
if (font) {
|
|
52
|
+
span.style.font = font.css;
|
|
53
|
+
if (font.monospacedDigit) span.style.fontVariantNumeric = 'tabular-nums';
|
|
54
|
+
}
|
|
52
55
|
// Every fragment shares the line's baseline (its height is the tallest run's).
|
|
53
56
|
span.style.lineHeight = 'inherit';
|
|
54
57
|
span.textContent = fragment.text;
|