@swiftbrowser/web 0.9.4 → 0.9.6
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 +32 -1
- package/package.json +1 -1
- package/src/browserHistory.ts +72 -3
- package/src/deviceMode.ts +239 -0
- package/src/hostInset.ts +26 -0
- 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 +55 -9
- package/src/main.ts +8 -1
- package/src/protocol.ts +15 -0
- package/src/renderer.ts +23 -1
- package/src/richText.ts +5 -2
- package/src/scrollAnchor.ts +110 -0
- package/src/styles.css +20 -4
package/README.md
CHANGED
|
@@ -247,7 +247,38 @@ chrome, `#screen` fills `100dvw × 100dvh` at (0, 0), the layout environment's
|
|
|
247
247
|
`getComputedStyle` (`renderer.setSafeArea`). They are non-zero only in
|
|
248
248
|
standalone (home screen) mode or landscape, which is what a native app would
|
|
249
249
|
get. `resize`, `orientationchange` and `visualViewport` resizes re-run the
|
|
250
|
-
engine (debounced 100ms, `renderer.viewportChanged`). The
|
|
250
|
+
engine (debounced 100ms, `renderer.viewportChanged`). The on-screen keyboard
|
|
251
|
+
does not resize `#screen` in iOS Safari (nor in a browser whose
|
|
252
|
+
`interactive-widget` is `resizes-visual`): the layout viewport keeps its
|
|
253
|
+
height and the browser shrinks (and pans) the visual viewport instead. (A
|
|
254
|
+
browser that does shrink the layout viewport for the keyboard goes through
|
|
255
|
+
the resize path above, and measures no keyboard.) `watchKeyboard` measures the
|
|
256
|
+
keyboard as the layout viewport's height less the visual one's, on every
|
|
257
|
+
`visualViewport` resize or scroll (once per frame, not debounced: the iOS
|
|
258
|
+
keyboard animates over a quarter of a second). The layout viewport's height
|
|
259
|
+
is `#device`'s (`100dvh`), not `window.innerHeight`, which iOS Safari shrinks
|
|
260
|
+
to the visual viewport's height while the keyboard is up. The browser's pan
|
|
261
|
+
to the focused field is not subtracted (Safari pans by the keyboard's whole
|
|
262
|
+
height for a field at the screen's bottom, which would read as no keyboard)
|
|
263
|
+
but undone: the page is scrolled back to its origin once the pan has held
|
|
264
|
+
still for a few frames, since Safari animates it with the keyboard's slide
|
|
265
|
+
and scrolling against that showed as a judder. Better still, the pan
|
|
266
|
+
mostly never happens: Safari pans only when the keyboard would cover the
|
|
267
|
+
field, so on focus the keyboard's last measured height (kept per layout
|
|
268
|
+
height in `localStorage`, so the next page load has it too) is reported at
|
|
269
|
+
once and the content is laid out above the keyboard before it shows; the
|
|
270
|
+
measurement then confirms or corrects it, and a reading of 0 in the second
|
|
271
|
+
after the focus is the keyboard not up yet. When the field loses focus and
|
|
272
|
+
no other text input takes it, 0 is reported as the keyboard starts down.
|
|
273
|
+
For a second after a text input gains or loses focus the viewport is read
|
|
274
|
+
every frame as well, in case its events come late or not at all;
|
|
275
|
+
`?debug=viewport` shows the numbers read, over the app, for checking a
|
|
276
|
+
phone's browser. The height goes to
|
|
277
|
+
`renderer.setKeyboard`, which the engine takes as a bottom safe-area inset
|
|
278
|
+
(`LayoutEnvironment.keyboard`; docs/ops-protocol.md, `ignoresKeyboard`): a
|
|
279
|
+
field or a chat's composer rises above the keyboard and a scroll view
|
|
280
|
+
anchored to its end keeps the last line in view, as on iOS, while a tab bar
|
|
281
|
+
under the keyboard stays put. The color scheme follows
|
|
251
282
|
`prefers-color-scheme` (and its changes), the Dynamic Type size is `large`, and
|
|
252
283
|
the `environment` event carries both as usual. `touch-action: manipulation`
|
|
253
284
|
and `-webkit-text-size-adjust: 100%` stop double-tap zoom and text inflation;
|
package/package.json
CHANGED
package/src/browserHistory.ts
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* screens to paths should set them with `replaceState` on the entry the
|
|
14
14
|
* renderer made.
|
|
15
15
|
*
|
|
16
|
+
* A two-column split view pushes nothing when its sidebar's selection
|
|
17
|
+
* changes, so that is an entry of its own: choosing another sidebar row
|
|
18
|
+
* (one the selection moves to, not the first choice) adds one, and back or
|
|
19
|
+
* forward taps the row the entry recorded, through the same `tap` a finger
|
|
20
|
+
* sends. In a compact width the selection pushes the detail, a level like
|
|
21
|
+
* any other.
|
|
22
|
+
*
|
|
16
23
|
* While enabled, the renderer's own edge swipe to pop is off for touch and
|
|
17
24
|
* pen: the browser's edge swipe (Safari's, Android's back gesture) is the
|
|
18
25
|
* swipe back, and it lands here as a `popstate`. A mouse drag from the edge
|
|
@@ -25,6 +32,8 @@ interface HistoryState {
|
|
|
25
32
|
sbHistory: true;
|
|
26
33
|
/** How many levels (screens and sheets) are above the base in this entry. */
|
|
27
34
|
sbDepth: number;
|
|
35
|
+
/** The selected sidebar rows of the visible two-column split views (`sbId`s, comma-separated; absent or empty for none). */
|
|
36
|
+
sbSelection?: string;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/** What the renderer's elements need to say for this to read them (`sbId`, `sbDepth` datasets). */
|
|
@@ -48,6 +57,8 @@ export class BrowserHistory {
|
|
|
48
57
|
*/
|
|
49
58
|
private movingTo: number | null = null;
|
|
50
59
|
private movingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
/** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
|
|
61
|
+
private restoringSelection = false;
|
|
51
62
|
private enabled = false;
|
|
52
63
|
|
|
53
64
|
constructor(
|
|
@@ -63,7 +74,7 @@ export class BrowserHistory {
|
|
|
63
74
|
if (this.enabled) return;
|
|
64
75
|
this.enabled = true;
|
|
65
76
|
const current = stateOf(this.win.history.state);
|
|
66
|
-
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}),
|
|
77
|
+
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
|
|
67
78
|
this.renderer.interaction.nativeBackGesture = true;
|
|
68
79
|
this.root.addEventListener('sb:commit', this.onCommit);
|
|
69
80
|
this.win.addEventListener('popstate', this.onPopState);
|
|
@@ -89,6 +100,36 @@ export class BrowserHistory {
|
|
|
89
100
|
return depth;
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
/** The selected sidebar rows of the visible two-column split views, as an entry records them. */
|
|
104
|
+
selection(): string {
|
|
105
|
+
const ids: string[] = [];
|
|
106
|
+
for (const split of this.root.querySelectorAll<HTMLElement>('.sb-navsplit')) {
|
|
107
|
+
if (!shown(this.win, split)) continue;
|
|
108
|
+
// The sidebar column is the first stack; its selectable rows carry the selection.
|
|
109
|
+
const sidebar = split.querySelector<HTMLElement>('.sb-navstack');
|
|
110
|
+
if (!sidebar) continue;
|
|
111
|
+
for (const row of sidebar.querySelectorAll<HTMLElement>('.sb-listrow[data-sb-selectable][data-sb-selected]')) {
|
|
112
|
+
const id = row.dataset['sbId'];
|
|
113
|
+
if (id !== undefined) ids.push(id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return ids.join(',');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Taps the sidebar rows an entry recorded as selected that are not selected now; false when none can be. */
|
|
120
|
+
private selectRows(recorded: string): boolean {
|
|
121
|
+
const current = new Set(this.selection().split(',').filter(Boolean));
|
|
122
|
+
let tapped = false;
|
|
123
|
+
for (const id of recorded.split(',').filter(Boolean)) {
|
|
124
|
+
if (current.has(id)) continue;
|
|
125
|
+
const row = this.root.querySelector<HTMLElement>(`.sb-listrow[data-sb-selectable][data-sb-id="${id}"]`);
|
|
126
|
+
if (!row || !shown(this.win, row)) continue;
|
|
127
|
+
this.renderer.tap(Number(id));
|
|
128
|
+
tapped = true;
|
|
129
|
+
}
|
|
130
|
+
return tapped;
|
|
131
|
+
}
|
|
132
|
+
|
|
92
133
|
private sheets(): HTMLElement[] {
|
|
93
134
|
return [...this.root.querySelectorAll<HTMLElement>(':scope > .sb-sheet')].filter((sheet) => shown(this.win, sheet));
|
|
94
135
|
}
|
|
@@ -135,6 +176,14 @@ export class BrowserHistory {
|
|
|
135
176
|
return this.movingTo ?? stateOf(this.win.history.state)?.sbDepth ?? 0;
|
|
136
177
|
}
|
|
137
178
|
|
|
179
|
+
private recordedSelection(): string {
|
|
180
|
+
return stateOf(this.win.history.state)?.sbSelection ?? '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private entry(depth: number, selection: string): HistoryState {
|
|
184
|
+
return selection ? { sbHistory: true, sbDepth: depth, sbSelection: selection } : { sbHistory: true, sbDepth: depth };
|
|
185
|
+
}
|
|
186
|
+
|
|
138
187
|
/** Our own `history.go`, remembered until its `popstate` lands (or a moment passes without one: a `go` past the ends fires none). */
|
|
139
188
|
private go(delta: number, to: number): void {
|
|
140
189
|
this.ignorePops += 1;
|
|
@@ -156,11 +205,12 @@ export class BrowserHistory {
|
|
|
156
205
|
|
|
157
206
|
private readonly onCommit = (): void => this.sync();
|
|
158
207
|
|
|
159
|
-
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them. */
|
|
208
|
+
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. */
|
|
160
209
|
sync(): void {
|
|
161
210
|
if (!this.enabled) return;
|
|
162
211
|
const depth = this.depth();
|
|
163
212
|
const recorded = this.recorded();
|
|
213
|
+
const selection = this.selection();
|
|
164
214
|
if (this.pendingPops > 0) {
|
|
165
215
|
// Dismissing for a `popstate`: the entry already names the target depth.
|
|
166
216
|
if (depth > recorded) {
|
|
@@ -170,11 +220,21 @@ export class BrowserHistory {
|
|
|
170
220
|
return;
|
|
171
221
|
}
|
|
172
222
|
if (depth > recorded) {
|
|
173
|
-
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(
|
|
223
|
+
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
|
|
174
224
|
} else if (depth < recorded) {
|
|
175
225
|
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
176
226
|
this.go(depth - recorded, depth);
|
|
227
|
+
} else if (this.movingTo === null && selection !== this.recordedSelection()) {
|
|
228
|
+
// The same levels, another sidebar selection. The first choice (from
|
|
229
|
+
// none) and the one a `popstate` asked for belong to the entry at hand;
|
|
230
|
+
// a move from one row to another is a new entry.
|
|
231
|
+
const history = this.win.history;
|
|
232
|
+
if (this.restoringSelection || !this.recordedSelection()) {
|
|
233
|
+
const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
|
|
234
|
+
history.replaceState({ ...rest, ...this.entry(depth, selection) }, '');
|
|
235
|
+
} else history.pushState(this.entry(depth, selection), '');
|
|
177
236
|
}
|
|
237
|
+
this.restoringSelection = false;
|
|
178
238
|
}
|
|
179
239
|
|
|
180
240
|
private readonly onPopState = (event: PopStateEvent): void => {
|
|
@@ -194,6 +254,15 @@ export class BrowserHistory {
|
|
|
194
254
|
} else if (target.sbDepth > depth) {
|
|
195
255
|
// Forward: a screen cannot be re-pushed; return to the entry that matches.
|
|
196
256
|
this.go(depth - target.sbDepth, depth);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// The entry's sidebar selection, where it differs: tap the rows it names
|
|
260
|
+
// (back and forward alike, a row can be chosen again). What the app
|
|
261
|
+
// shows after that is recorded on this entry rather than pushed.
|
|
262
|
+
const recorded = target.sbSelection ?? '';
|
|
263
|
+
if (recorded !== this.selection()) {
|
|
264
|
+
this.restoringSelection = true;
|
|
265
|
+
this.selectRows(recorded);
|
|
197
266
|
}
|
|
198
267
|
};
|
|
199
268
|
}
|
package/src/deviceMode.ts
CHANGED
|
@@ -85,3 +85,242 @@ export function watchViewport(onChange: () => void, debounceMs = 100): () => voi
|
|
|
85
85
|
vv?.removeEventListener('resize', schedule);
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/** What `keyboardHeight` reads of the visual viewport (`window.visualViewport`). */
|
|
90
|
+
export interface VisualViewportLike {
|
|
91
|
+
height: number;
|
|
92
|
+
scale: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The height of the on-screen keyboard over the bottom of the layout
|
|
97
|
+
* viewport, in CSS px: 0 while it is down. `layoutHeight` is the layout
|
|
98
|
+
* viewport's height: the height of the root element that fills it
|
|
99
|
+
* (`100dvh`), not `window.innerHeight`, which iOS Safari shrinks to the
|
|
100
|
+
* visual viewport's height while the keyboard is up (an iPhone read inner
|
|
101
|
+
* 352, visual 352, and the root still 692 tall). iOS Safari (and a browser
|
|
102
|
+
* whose `interactive-widget` is `resizes-visual`) does not shrink the layout
|
|
103
|
+
* viewport for the keyboard; it shrinks the visual viewport to the part of
|
|
104
|
+
* the screen above the keyboard and pans it down the page so the focused
|
|
105
|
+
* field shows. The keyboard is therefore the difference of the two heights.
|
|
106
|
+
* The pan (`offsetTop`) is not subtracted: Safari pans by the keyboard's
|
|
107
|
+
* full height when the field is at the bottom of the screen, which would
|
|
108
|
+
* read as no keyboard at all; `watchKeyboard` undoes the pan instead. A
|
|
109
|
+
* browser that shrinks the layout viewport for the keyboard measures 0 here
|
|
110
|
+
* and resizes the root. A pinch zoom shrinks the visual viewport too, so a
|
|
111
|
+
* scale other than 1 counts as no keyboard.
|
|
112
|
+
*/
|
|
113
|
+
export function keyboardHeight(layoutHeight: number, vv: VisualViewportLike | null | undefined): number {
|
|
114
|
+
if (!vv || !Number.isFinite(vv.height) || !Number.isFinite(layoutHeight) || Math.abs((vv.scale || 1) - 1) > 0.01) return 0;
|
|
115
|
+
const covered = layoutHeight - vv.height;
|
|
116
|
+
return covered > 0 ? Math.round(covered) : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WatchKeyboardOptions {
|
|
120
|
+
/** The element that fills the layout viewport (`#device`): its height is the layout viewport's. */
|
|
121
|
+
root: HTMLElement;
|
|
122
|
+
/** Gets every reading (the `?debug=viewport` readout). */
|
|
123
|
+
onRead?: ((reading: ViewportReading) => void) | undefined;
|
|
124
|
+
/** Where the last measured height is kept for the next focus (and the next page load); `localStorage` by default, null for none. */
|
|
125
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'> | null | undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Call `onChange` with the keyboard's height (`keyboardHeight`) whenever the
|
|
130
|
+
* visual viewport resizes or pans, once per animation frame: the iOS
|
|
131
|
+
* keyboard animates over a quarter of a second and sends several resizes,
|
|
132
|
+
* so unlike `watchViewport` this does not debounce. A text input gaining or
|
|
133
|
+
* losing focus is what raises or drops the keyboard, so for a second after
|
|
134
|
+
* either the viewport is read every frame as well, in case its events come
|
|
135
|
+
* late or not at all.
|
|
136
|
+
*
|
|
137
|
+
* The browser pans the page to the focused field when the keyboard would
|
|
138
|
+
* cover it, which would leave the top of the screen (the navigation bar)
|
|
139
|
+
* scrolled off, and it does so while the keyboard slides, so undoing the
|
|
140
|
+
* pan as it happens shows as a judder. Better that it never happens: on
|
|
141
|
+
* focus the keyboard's last measured height (kept per layout height, in
|
|
142
|
+
* `localStorage` so the next page load has it too) is reported at once,
|
|
143
|
+
* before the keyboard shows, so the app has laid its content out above the
|
|
144
|
+
* keyboard, field included, by the time the browser decides whether to pan.
|
|
145
|
+
* The measurement then confirms or corrects it; a reading of 0 during the
|
|
146
|
+
* second after the focus is the keyboard not up yet, not a correction. A
|
|
147
|
+
* pan that does happen (the first focus ever, a keyboard taller than the
|
|
148
|
+
* last) is scrolled back, once it has held still for a few frames: Safari
|
|
149
|
+
* animates its pan with the keyboard's slide, and scrolling against that
|
|
150
|
+
* animation showed as a judder. (Safari's pan is a window scroll, `scrollY`
|
|
151
|
+
* 340 for a 340 keyboard on an iPhone; moving the root to counter a pan read
|
|
152
|
+
* from `visualViewport.offsetTop` was tried and dipped the content mid-slide
|
|
153
|
+
* on passing offsets.) When the field loses focus and no other text input
|
|
154
|
+
* has it, 0 is reported at once too, as the keyboard starts down. Returns a
|
|
155
|
+
* function that stops listening.
|
|
156
|
+
*/
|
|
157
|
+
export function watchKeyboard(onChange: (height: number) => void, options: WatchKeyboardOptions): () => void {
|
|
158
|
+
const vv = window.visualViewport;
|
|
159
|
+
if (!vv) return () => {};
|
|
160
|
+
const { root, onRead } = options;
|
|
161
|
+
const storage = options.storage === undefined ? safeLocalStorage() : options.storage;
|
|
162
|
+
let frame: number | undefined;
|
|
163
|
+
let last = 0;
|
|
164
|
+
/** While set, the viewport is read every frame until this time (performance.now() ms). */
|
|
165
|
+
let pollUntil = 0;
|
|
166
|
+
/** While set, a reading of 0 is the keyboard not up yet (a focus just reported the known height). */
|
|
167
|
+
let anticipatingUntil = 0;
|
|
168
|
+
let blurTimer: ReturnType<typeof setTimeout> | undefined;
|
|
169
|
+
let lastEvent = '';
|
|
170
|
+
/** The pan read last frame (`scrollY:offsetTop`) and for how many frames in a row it has held. */
|
|
171
|
+
let lastPan = '';
|
|
172
|
+
let panHeld = 0;
|
|
173
|
+
const storageKey = (): string => `sb-keyboard-${root.clientHeight}`;
|
|
174
|
+
const knownHeight = (): number => {
|
|
175
|
+
const value = Number(storage?.getItem(storageKey()) ?? 0);
|
|
176
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
177
|
+
};
|
|
178
|
+
const remember = (height: number): void => {
|
|
179
|
+
try {
|
|
180
|
+
storage?.setItem(storageKey(), String(height));
|
|
181
|
+
} catch {
|
|
182
|
+
// Storage full or blocked: the next focus measures instead of anticipating.
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const report = (height: number): void => {
|
|
186
|
+
if (height === last) return;
|
|
187
|
+
last = height;
|
|
188
|
+
onChange(height);
|
|
189
|
+
};
|
|
190
|
+
const update = (): void => {
|
|
191
|
+
frame = undefined;
|
|
192
|
+
const layoutHeight = root.clientHeight;
|
|
193
|
+
const height = keyboardHeight(layoutHeight, vv);
|
|
194
|
+
const up = height > 0 || (last > 0 && performance.now() < anticipatingUntil);
|
|
195
|
+
const panNow = `${Math.round(window.scrollY)}:${Math.round(vv.offsetTop)}`;
|
|
196
|
+
panHeld = panNow === lastPan ? panHeld + 1 : 0;
|
|
197
|
+
lastPan = panNow;
|
|
198
|
+
const settled = panHeld >= PAN_SETTLE_FRAMES;
|
|
199
|
+
if (up && settled && (window.scrollY !== 0 || window.scrollX !== 0)) window.scrollTo(0, 0);
|
|
200
|
+
onRead?.({
|
|
201
|
+
layoutHeight,
|
|
202
|
+
innerHeight: window.innerHeight,
|
|
203
|
+
visualHeight: vv.height,
|
|
204
|
+
scale: vv.scale,
|
|
205
|
+
offsetTop: vv.offsetTop,
|
|
206
|
+
pageTop: vv.pageTop,
|
|
207
|
+
scrollY: window.scrollY,
|
|
208
|
+
keyboard: height,
|
|
209
|
+
event: lastEvent,
|
|
210
|
+
});
|
|
211
|
+
// The readout wants every frame; otherwise only the second after a focus change.
|
|
212
|
+
if (onRead || performance.now() < pollUntil) schedule();
|
|
213
|
+
if (height > 0) {
|
|
214
|
+
anticipatingUntil = 0;
|
|
215
|
+
remember(height);
|
|
216
|
+
report(height);
|
|
217
|
+
} else if (performance.now() >= anticipatingUntil) {
|
|
218
|
+
report(0);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const schedule = (): void => {
|
|
222
|
+
if (frame === undefined) frame = requestAnimationFrame(update);
|
|
223
|
+
};
|
|
224
|
+
const onViewport = (event: Event): void => {
|
|
225
|
+
lastEvent = `visualViewport ${event.type}`;
|
|
226
|
+
// Read on for a moment after a change, to see the pan hold still.
|
|
227
|
+
pollUntil = Math.max(pollUntil, performance.now() + PAN_SETTLE_MS);
|
|
228
|
+
schedule();
|
|
229
|
+
};
|
|
230
|
+
const isTextInput = (target: EventTarget | Element | null): boolean =>
|
|
231
|
+
target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
|
|
232
|
+
const onFocus = (event: FocusEvent): void => {
|
|
233
|
+
if (!isTextInput(event.target)) return;
|
|
234
|
+
lastEvent = event.type;
|
|
235
|
+
pollUntil = performance.now() + FOCUS_POLL_MS;
|
|
236
|
+
if (blurTimer !== undefined) clearTimeout(blurTimer);
|
|
237
|
+
blurTimer = undefined;
|
|
238
|
+
if (event.type === 'focusin') {
|
|
239
|
+
const known = knownHeight();
|
|
240
|
+
if (known > 0) {
|
|
241
|
+
anticipatingUntil = pollUntil;
|
|
242
|
+
report(known);
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
anticipatingUntil = 0;
|
|
246
|
+
// The focus may be moving to another text input (its `focusin` follows at once): wait a moment.
|
|
247
|
+
blurTimer = setTimeout(() => {
|
|
248
|
+
blurTimer = undefined;
|
|
249
|
+
if (!isTextInput(document.activeElement)) report(0);
|
|
250
|
+
}, BLUR_SETTLE_MS);
|
|
251
|
+
}
|
|
252
|
+
schedule();
|
|
253
|
+
};
|
|
254
|
+
vv.addEventListener('resize', onViewport);
|
|
255
|
+
vv.addEventListener('scroll', onViewport);
|
|
256
|
+
document.addEventListener('focusin', onFocus);
|
|
257
|
+
document.addEventListener('focusout', onFocus);
|
|
258
|
+
return () => {
|
|
259
|
+
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
260
|
+
if (blurTimer !== undefined) clearTimeout(blurTimer);
|
|
261
|
+
pollUntil = 0;
|
|
262
|
+
anticipatingUntil = 0;
|
|
263
|
+
vv.removeEventListener('resize', onViewport);
|
|
264
|
+
vv.removeEventListener('scroll', onViewport);
|
|
265
|
+
document.removeEventListener('focusin', onFocus);
|
|
266
|
+
document.removeEventListener('focusout', onFocus);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** `localStorage`, or null where reading it throws (a private window, blocked site data). */
|
|
271
|
+
function safeLocalStorage(): Storage | null {
|
|
272
|
+
try {
|
|
273
|
+
return window.localStorage;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** After a text input loses focus, how long to wait for another to take it before reporting the keyboard down. */
|
|
280
|
+
const BLUR_SETTLE_MS = 60;
|
|
281
|
+
|
|
282
|
+
/** How many frames in a row the browser's pan must hold still before it is scrolled back. */
|
|
283
|
+
const PAN_SETTLE_FRAMES = 3;
|
|
284
|
+
|
|
285
|
+
/** How long after a visual viewport event the viewport keeps being read every frame, to see the pan settle. */
|
|
286
|
+
const PAN_SETTLE_MS = 250;
|
|
287
|
+
|
|
288
|
+
/** How long after a text input gains or loses focus the viewport is read every frame (the keyboard's animation and some). */
|
|
289
|
+
const FOCUS_POLL_MS = 1000;
|
|
290
|
+
|
|
291
|
+
/** One reading of the viewport, for the `?debug=viewport` readout. */
|
|
292
|
+
export interface ViewportReading {
|
|
293
|
+
/** The root's height: the layout viewport's. */
|
|
294
|
+
layoutHeight: number;
|
|
295
|
+
innerHeight: number;
|
|
296
|
+
visualHeight: number;
|
|
297
|
+
scale: number;
|
|
298
|
+
/** The visual viewport's offset within the layout viewport: the browser's pan to the focused field. */
|
|
299
|
+
offsetTop: number;
|
|
300
|
+
pageTop: number;
|
|
301
|
+
scrollY: number;
|
|
302
|
+
keyboard: number;
|
|
303
|
+
/** The event that led to this reading (`visualViewport resize`, `focusin`…), empty before the first. */
|
|
304
|
+
event: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `?debug=viewport`: a small readout over the app with the numbers the
|
|
309
|
+
* keyboard measurement reads, for checking a phone's browser (which has no
|
|
310
|
+
* console to hand). It follows the visual viewport (a fixed element is
|
|
311
|
+
* panned with the layout viewport, off the top of the screen, when the
|
|
312
|
+
* browser pans to a field). Returns the function that updates it.
|
|
313
|
+
*/
|
|
314
|
+
export function installViewportReadout(): (reading: ViewportReading) => void {
|
|
315
|
+
const el = document.createElement('pre');
|
|
316
|
+
el.className = 'sb-viewport-readout';
|
|
317
|
+
el.setAttribute('aria-hidden', 'true');
|
|
318
|
+
document.body.appendChild(el);
|
|
319
|
+
return (r) => {
|
|
320
|
+
el.style.transform = `translateY(${Math.round(r.offsetTop)}px)`;
|
|
321
|
+
el.textContent =
|
|
322
|
+
`root ${window.innerWidth}×${r.layoutHeight} inner ${r.innerHeight} visual ${Math.round(r.visualHeight)} scale ${r.scale.toFixed(3)}\n` +
|
|
323
|
+
`offsetTop ${Math.round(r.offsetTop)} pageTop ${Math.round(r.pageTop)} scrollY ${Math.round(r.scrollY)}\n` +
|
|
324
|
+
`keyboard ${r.keyboard} ${r.event || 'no event yet'} ${new Date().toLocaleTimeString()}`;
|
|
325
|
+
};
|
|
326
|
+
}
|
package/src/hostInset.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `host` that is a list row is drawn in the row's box (the card's width,
|
|
3
|
+
* the row's height with its padding), as every row is, while the engine
|
|
4
|
+
* measured and placed its content in the row's content frame inside it.
|
|
5
|
+
* The content element goes back into that frame, as a text row's text is
|
|
6
|
+
* padded into its own (layoutDom.ts), so DOM content that fills its box
|
|
7
|
+
* (`sizing: "fill"`, `height: 100%`) fills what it was measured for and
|
|
8
|
+
* not the row's padding too.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Rect } from './layout';
|
|
12
|
+
|
|
13
|
+
export interface HostContentInset {
|
|
14
|
+
left: number;
|
|
15
|
+
top: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where the content sits inside the element's box; null when the box is the frame (nothing to set). */
|
|
21
|
+
export function hostContentInset(box: Rect, frame: Rect): HostContentInset | null {
|
|
22
|
+
const left = frame.x - box.x;
|
|
23
|
+
const top = frame.y - box.y;
|
|
24
|
+
if (left === 0 && top === 0 && frame.width === box.width && frame.height === box.height) return null;
|
|
25
|
+
return { left, top, width: frame.width, height: frame.height };
|
|
26
|
+
}
|
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
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
|
|
37
|
+
import { hostContentInset } from './hostInset';
|
|
37
38
|
import { listRowBackgroundOf } from './layout';
|
|
38
39
|
import { styleToCSS } from './styles';
|
|
39
40
|
import type { ShapeStyle } from './protocol';
|
|
@@ -118,15 +119,8 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
118
119
|
ids = result.frames.keys();
|
|
119
120
|
}
|
|
120
121
|
|
|
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
|
-
}
|
|
122
|
+
/** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
|
|
123
|
+
const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
|
|
130
124
|
const box = drawnBox(node, boxOf(ctx, result, node, frame));
|
|
131
125
|
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
126
|
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
@@ -138,11 +132,37 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
138
132
|
if (node.kind === 'sheet') positionSheetCard(el, box);
|
|
139
133
|
else positionBox(el, box, origin);
|
|
140
134
|
}
|
|
135
|
+
return { box, moved, previous };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const visited = result.changed ? new Set(ids) : null;
|
|
139
|
+
for (const id of ids) {
|
|
140
|
+
const frame = result.frames.get(id);
|
|
141
|
+
const el = ctx.elements.get(id);
|
|
142
|
+
const node = ctx.nodes.get(id);
|
|
143
|
+
if (!el || !node) continue;
|
|
144
|
+
if (!frame) {
|
|
145
|
+
boxes.delete(id);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const { box, moved, previous } = applyBox(id, node, el, frame);
|
|
141
149
|
// A former menu item that is laid out again (moved out of the menu), or a
|
|
142
150
|
// `viewthatfits` candidate chosen again, shows again.
|
|
143
151
|
if (el.hidden) el.hidden = false;
|
|
144
152
|
applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
|
|
145
153
|
if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
|
|
154
|
+
// A child the engine did not touch kept its screen frame, but if this
|
|
155
|
+
// element's box moved, the child's `left`/`top` (relative to it) did
|
|
156
|
+
// not: the plus button at the bottom of a chat's composer stays put on
|
|
157
|
+
// screen while the composer grows upwards around it. Re-place such
|
|
158
|
+
// children; their own subtrees are relative to them and unchanged.
|
|
159
|
+
if (!moved || !visited) continue;
|
|
160
|
+
for (const child of node.children) {
|
|
161
|
+
if (visited.has(child.id)) continue;
|
|
162
|
+
const childFrame = result.frames.get(child.id);
|
|
163
|
+
const childEl = ctx.elements.get(child.id);
|
|
164
|
+
if (childFrame && childEl) applyBox(child.id, child, childEl, childFrame);
|
|
165
|
+
}
|
|
146
166
|
}
|
|
147
167
|
|
|
148
168
|
// Phase 6: menu items are hidden without a frame (tabs keep theirs, above);
|
|
@@ -414,6 +434,9 @@ function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): vo
|
|
|
414
434
|
if (!font) return;
|
|
415
435
|
const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
|
|
416
436
|
if (el.style.font !== css) el.style.font = css;
|
|
437
|
+
// The `font` shorthand resets the variant, so it goes after it.
|
|
438
|
+
const variant = font.monospacedDigit ? 'tabular-nums' : '';
|
|
439
|
+
if (el.style.fontVariantNumeric !== variant) el.style.fontVariantNumeric = variant;
|
|
417
440
|
}
|
|
418
441
|
|
|
419
442
|
const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
|
|
@@ -466,6 +489,26 @@ function setFlag(el: HTMLElement, key: string, value: string | null): void {
|
|
|
466
489
|
}
|
|
467
490
|
}
|
|
468
491
|
|
|
492
|
+
/**
|
|
493
|
+
* A host's content element goes back into the engine's frame when the host
|
|
494
|
+
* is drawn in a bigger box (a list row's): src/hostInset.ts. In its own
|
|
495
|
+
* frame the stylesheet's `width: 100%` (and `height: 100%` for `fill`) hold.
|
|
496
|
+
*/
|
|
497
|
+
function positionHostContent(el: HTMLElement, box: Rect, frame: Rect): void {
|
|
498
|
+
const content = el.querySelector<HTMLElement>(':scope > .sb-host-content');
|
|
499
|
+
if (!content) return;
|
|
500
|
+
const inset = hostContentInset(box, frame);
|
|
501
|
+
const s = content.style;
|
|
502
|
+
const left = inset ? `${inset.left}px` : '';
|
|
503
|
+
const top = inset ? `${inset.top}px` : '';
|
|
504
|
+
const width = inset ? `${inset.width}px` : '';
|
|
505
|
+
const height = inset ? `${inset.height}px` : '';
|
|
506
|
+
if (s.left !== left) s.left = left;
|
|
507
|
+
if (s.top !== top) s.top = top;
|
|
508
|
+
if (s.width !== width) s.width = width;
|
|
509
|
+
if (s.height !== height) s.height = height;
|
|
510
|
+
}
|
|
511
|
+
|
|
469
512
|
/**
|
|
470
513
|
* For leaf rows: pad the content frame back into place inside the row box.
|
|
471
514
|
* `shift` moves vertically centered content (a one-line field's text) down
|
|
@@ -595,6 +638,9 @@ function applyChrome(
|
|
|
595
638
|
padIntoFrame(el, box, frame);
|
|
596
639
|
return;
|
|
597
640
|
}
|
|
641
|
+
case 'host':
|
|
642
|
+
positionHostContent(el, box, frame);
|
|
643
|
+
return;
|
|
598
644
|
case 'image': {
|
|
599
645
|
const font = result.fonts.get(node.id);
|
|
600
646
|
if (font) {
|
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 {
|
|
@@ -250,6 +250,13 @@ if (deviceMode) {
|
|
|
250
250
|
// A rotation or window resize may cross the compact / regular threshold.
|
|
251
251
|
if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
|
|
252
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 });
|
|
253
260
|
}
|
|
254
261
|
|
|
255
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
|
|
|
@@ -1284,6 +1301,8 @@ export class Renderer {
|
|
|
1284
1301
|
};
|
|
1285
1302
|
const changes = applyLayout(ctx, result);
|
|
1286
1303
|
this.boxes = ctx.boxes;
|
|
1304
|
+
// `.defaultScrollAnchor`: scroll views that keep their end (or another point) in view as they grow.
|
|
1305
|
+
keepScrollAnchors();
|
|
1287
1306
|
if (this.contentHeight) {
|
|
1288
1307
|
// The island's root is as tall as its content.
|
|
1289
1308
|
let bottom = 0;
|
|
@@ -3114,6 +3133,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3114
3133
|
else delete el.dataset['sbSnap'];
|
|
3115
3134
|
if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
3116
3135
|
applyEdgeEffect(el, scroll.edgeEffect);
|
|
3136
|
+
applyDefaultScrollAnchor(el, scroll.defaultScrollAnchor);
|
|
3117
3137
|
break;
|
|
3118
3138
|
}
|
|
3119
3139
|
case 'section':
|
|
@@ -3826,7 +3846,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3826
3846
|
}
|
|
3827
3847
|
|
|
3828
3848
|
if (style.font) {
|
|
3829
|
-
const { size, weight, design, textStyle, relativeTo } = style.font;
|
|
3849
|
+
const { size, weight, design, textStyle, relativeTo, monospacedDigit } = style.font;
|
|
3830
3850
|
if (size != null) {
|
|
3831
3851
|
// A fixed size; `relativeTo` scales it like that text style (the
|
|
3832
3852
|
// scale factor is a CSS variable the page sets per Dynamic Type size).
|
|
@@ -3849,6 +3869,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3849
3869
|
else if (design === 'rounded') s.fontFamily = FONT_FAMILY_ROUNDED;
|
|
3850
3870
|
else if (design === 'serif') s.fontFamily = FONT_FAMILY_SERIF;
|
|
3851
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';
|
|
3852
3874
|
}
|
|
3853
3875
|
|
|
3854
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;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.defaultScrollAnchor(_:)` on a `ScrollView` (docs/ops-protocol.md, "Default
|
|
3
|
+
* scroll anchor"): the scroll view carries `defaultScrollAnchor`, a unit
|
|
4
|
+
* point. The content starts there (`.bottom`: the end is in view, as a chat
|
|
5
|
+
* opens on its latest message), and when the content or the viewport changes
|
|
6
|
+
* size in a pass, the anchor is kept *if the viewer was at it* before the
|
|
7
|
+
* pass: new lines at the end of a chat keep the end in view, while a viewer
|
|
8
|
+
* who scrolled up to read stays where they are. The offset before the pass
|
|
9
|
+
* is the one scrollSync.ts read for it, so a pass's own writes do not count
|
|
10
|
+
* as the viewer's scrolling.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { UnitPoint } from './protocol';
|
|
14
|
+
import { scrollTopOf } from './scrollSync';
|
|
15
|
+
|
|
16
|
+
/** The scroll offset on one axis that puts the anchor fraction of the content at the same fraction of the viewport. */
|
|
17
|
+
export function anchoredOffset(content: number, viewport: number, anchor: number): number {
|
|
18
|
+
return Math.max(0, Math.round((content - viewport) * anchor));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Within a couple of pixels of the anchor: browsers round offsets, and a smooth scroll may stop short. */
|
|
22
|
+
export const ANCHOR_TOLERANCE = 2;
|
|
23
|
+
|
|
24
|
+
export function isAtAnchor(offset: number, content: number, viewport: number, anchor: number): boolean {
|
|
25
|
+
return Math.abs(offset - anchoredOffset(content, viewport, anchor)) <= ANCHOR_TOLERANCE;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What `keepAnchor` reads and writes on a scrolling element; `HTMLElement` fits. */
|
|
29
|
+
export interface AnchoredScroller {
|
|
30
|
+
scrollTop: number;
|
|
31
|
+
scrollLeft: number;
|
|
32
|
+
scrollHeight: number;
|
|
33
|
+
scrollWidth: number;
|
|
34
|
+
clientHeight: number;
|
|
35
|
+
clientWidth: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The extents a scroller had after the last pass. */
|
|
39
|
+
interface Extents {
|
|
40
|
+
content: { width: number; height: number };
|
|
41
|
+
viewport: { width: number; height: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* After a pass laid the scroller out: on its first pass the content goes to
|
|
46
|
+
* the anchor; after that, an axis whose extents changed keeps the anchor
|
|
47
|
+
* when `offsetBefore` (the viewer's position before the pass) was at it.
|
|
48
|
+
* Returns the extents to remember for the next pass.
|
|
49
|
+
*/
|
|
50
|
+
export function keepAnchor(
|
|
51
|
+
el: AnchoredScroller,
|
|
52
|
+
anchor: UnitPoint,
|
|
53
|
+
axes: { horizontal: boolean; vertical: boolean },
|
|
54
|
+
before: Extents | undefined,
|
|
55
|
+
offsetBefore: { top: number; left: number },
|
|
56
|
+
): Extents {
|
|
57
|
+
const now: Extents = { content: { width: el.scrollWidth, height: el.scrollHeight }, viewport: { width: el.clientWidth, height: el.clientHeight } };
|
|
58
|
+
if (axes.vertical) {
|
|
59
|
+
const changed = !before || before.content.height !== now.content.height || before.viewport.height !== now.viewport.height;
|
|
60
|
+
const wasAt = !before || isAtAnchor(offsetBefore.top, before.content.height, before.viewport.height, anchor.y);
|
|
61
|
+
if (changed && wasAt) el.scrollTop = anchoredOffset(now.content.height, now.viewport.height, anchor.y);
|
|
62
|
+
}
|
|
63
|
+
if (axes.horizontal) {
|
|
64
|
+
const changed = !before || before.content.width !== now.content.width || before.viewport.width !== now.viewport.width;
|
|
65
|
+
const wasAt = !before || isAtAnchor(offsetBefore.left, before.content.width, before.viewport.width, anchor.x);
|
|
66
|
+
if (changed && wasAt) el.scrollLeft = anchoredOffset(now.content.width, now.viewport.width, anchor.x);
|
|
67
|
+
}
|
|
68
|
+
return now;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const anchors = new WeakMap<HTMLElement, UnitPoint>();
|
|
72
|
+
const extents = new WeakMap<HTMLElement, Extents>();
|
|
73
|
+
const anchored = new Set<HTMLElement>();
|
|
74
|
+
|
|
75
|
+
/** A scroll view's `defaultScrollAnchor` prop, kept for the passes to come (none: the view scrolls as any other). */
|
|
76
|
+
export function applyDefaultScrollAnchor(el: HTMLElement, anchor: UnitPoint | undefined): void {
|
|
77
|
+
if (anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number') {
|
|
78
|
+
anchors.set(el, { x: anchor.x, y: anchor.y });
|
|
79
|
+
anchored.add(el);
|
|
80
|
+
} else {
|
|
81
|
+
anchors.delete(el);
|
|
82
|
+
extents.delete(el);
|
|
83
|
+
anchored.delete(el);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The axes a `scrollview` element scrolls on, from its `data-sb-axes`. */
|
|
88
|
+
function axesOf(el: HTMLElement): { horizontal: boolean; vertical: boolean } {
|
|
89
|
+
const axes = el.dataset['sbAxes'] ?? 'vertical';
|
|
90
|
+
return { horizontal: axes === 'horizontal' || axes === 'both', vertical: axes !== 'horizontal' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Called by the renderer after each pass's frames are applied: every anchored scroll view keeps (or takes) its anchor. */
|
|
94
|
+
export function keepScrollAnchors(): void {
|
|
95
|
+
for (const el of anchored) {
|
|
96
|
+
if (!el.isConnected) {
|
|
97
|
+
anchored.delete(el);
|
|
98
|
+
extents.delete(el);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const anchor = anchors.get(el);
|
|
102
|
+
if (!anchor) continue;
|
|
103
|
+
extents.set(el, keepAnchor(el, anchor, axesOf(el), extents.get(el), { top: scrollTopOf(el), left: el.scrollLeft }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** For tests: forget every anchored scroller. */
|
|
108
|
+
export function resetScrollAnchors(): void {
|
|
109
|
+
anchored.clear();
|
|
110
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -396,6 +396,22 @@ html[data-sb-mode="device"] .device {
|
|
|
396
396
|
box-shadow: none;
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
/* `?debug=viewport` (src/deviceMode.ts installViewportReadout): the viewport
|
|
400
|
+
numbers the keyboard measurement reads, over the app, under the status bar. */
|
|
401
|
+
.sb-viewport-readout {
|
|
402
|
+
position: fixed;
|
|
403
|
+
top: calc(var(--sb-inset-top) + 4px);
|
|
404
|
+
left: 8px;
|
|
405
|
+
z-index: 1000;
|
|
406
|
+
margin: 0;
|
|
407
|
+
padding: 4px 6px;
|
|
408
|
+
font: 11px/1.3 ui-monospace, Menlo, monospace;
|
|
409
|
+
color: #fff;
|
|
410
|
+
background: rgba(0, 0, 0, 0.7);
|
|
411
|
+
border-radius: 6px;
|
|
412
|
+
pointer-events: none;
|
|
413
|
+
}
|
|
414
|
+
|
|
399
415
|
html[data-sb-mode="device"] .device::before,
|
|
400
416
|
html[data-sb-mode="device"] .device::after,
|
|
401
417
|
html[data-sb-mode="device"] .dynamic-island,
|
|
@@ -1162,12 +1178,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1162
1178
|
|
|
1163
1179
|
.sb-navbar-title {
|
|
1164
1180
|
/* Positioned at the engine's `title` rect (Phase 6), centered and truncated
|
|
1165
|
-
to the room the back button and the toolbar items leave
|
|
1166
|
-
|
|
1167
|
-
|
|
1181
|
+
to the room the back button and the toolbar items leave; the rect is the
|
|
1182
|
+
only limit on its width (a wide bar on an iPad gives a long title the
|
|
1183
|
+
room). Not a grid item: an absolutely positioned grid child would be
|
|
1184
|
+
placed against its grid area, not the bar's padding box. */
|
|
1168
1185
|
position: absolute;
|
|
1169
1186
|
display: block;
|
|
1170
|
-
max-width: 220px;
|
|
1171
1187
|
overflow: hidden;
|
|
1172
1188
|
text-overflow: ellipsis;
|
|
1173
1189
|
white-space: nowrap;
|