@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/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;
|
|
@@ -292,6 +323,25 @@ remains for the landing page and for `/app/` without a parameter. In the
|
|
|
292
323
|
`/manifest.webmanifest` is the app's, and the page is titled by the app's
|
|
293
324
|
display name alone.
|
|
294
325
|
|
|
326
|
+
## Browser history
|
|
327
|
+
|
|
328
|
+
`src/browserHistory.ts` mirrors the app's navigation in the browser's history
|
|
329
|
+
on the app page: pushing a screen or presenting a sheet adds an entry, and the
|
|
330
|
+
browser's back (a phone's edge swipe, the toolbar button, Alt+Left) pops or
|
|
331
|
+
dismisses it through the same `tap` the bar's back button and the sheet's
|
|
332
|
+
scrim send, so Swift's state stays the source of truth. A pop the app makes
|
|
333
|
+
itself takes the history back with it, and forward returns to the entry that
|
|
334
|
+
matches rather than re-pushing. Depth is read from the DOM after every
|
|
335
|
+
`commit`: the presented sheets plus the pushed screens of each visible stack
|
|
336
|
+
(the selected tab's, both columns of a split view, the top sheet's). The URL
|
|
337
|
+
is left alone. While it is on, the renderer's own edge swipe ignores touch and
|
|
338
|
+
pen: the browser's edge swipe is the swipe back, and it arrives as a
|
|
339
|
+
`popstate` (a mouse drag from the edge still pops through the renderer).
|
|
340
|
+
Its own `history.go` after a pop the app made itself is remembered until the
|
|
341
|
+
`popstate` lands, so commits meanwhile do not go back again.
|
|
342
|
+
An island has it on by default and opts out with `mount(..., { history: false })`
|
|
343
|
+
(`<SwiftUIView history={false}>` in @swiftbrowser/react).
|
|
344
|
+
|
|
295
345
|
## Deploying (Cloudflare Pages)
|
|
296
346
|
|
|
297
347
|
`npm run build` writes a static site to `dist/`: `index.html`, `app/index.html`,
|
package/package.json
CHANGED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's history as the app's navigation: pushing a screen or
|
|
3
|
+
* presenting a sheet adds a history entry, and the browser's back (a
|
|
4
|
+
* phone's edge swipe, the toolbar button, Alt+Left) pops or dismisses it,
|
|
5
|
+
* as the bar's own back button would. Forward cannot re-push a screen, so a
|
|
6
|
+
* forward step lands back where the app is.
|
|
7
|
+
*
|
|
8
|
+
* Depth is read from the DOM after every commit: the presented sheets, plus
|
|
9
|
+
* the pushed screens of every visible stack (the selected tab's, both
|
|
10
|
+
* columns of a split view, the top sheet's). Pops go through the same event
|
|
11
|
+
* the back button and the scrim send, so the app's own state stays the
|
|
12
|
+
* source of truth; the page's URL is never touched, and an app that maps
|
|
13
|
+
* screens to paths should set them with `replaceState` on the entry the
|
|
14
|
+
* renderer made.
|
|
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
|
+
*
|
|
23
|
+
* While enabled, the renderer's own edge swipe to pop is off for touch and
|
|
24
|
+
* pen: the browser's edge swipe (Safari's, Android's back gesture) is the
|
|
25
|
+
* swipe back, and it lands here as a `popstate`. A mouse drag from the edge
|
|
26
|
+
* still pops through the renderer, since no browser turns one into history.
|
|
27
|
+
*/
|
|
28
|
+
import type { Renderer } from './renderer';
|
|
29
|
+
|
|
30
|
+
interface HistoryState {
|
|
31
|
+
/** Our marker; entries without it belong to the page or the app. */
|
|
32
|
+
sbHistory: true;
|
|
33
|
+
/** How many levels (screens and sheets) are above the base in this entry. */
|
|
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;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the renderer's elements need to say for this to read them (`sbId`, `sbDepth` datasets). */
|
|
40
|
+
const stateOf = (value: unknown): HistoryState | null => (typeof value === 'object' && value !== null && (value as HistoryState).sbHistory === true ? (value as HistoryState) : null);
|
|
41
|
+
|
|
42
|
+
function shown(win: Window, el: HTMLElement): boolean {
|
|
43
|
+
if (el.hidden || el.getClientRects().length === 0) return false;
|
|
44
|
+
return win.getComputedStyle(el).visibility !== 'hidden';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class BrowserHistory {
|
|
48
|
+
private readonly win: Window;
|
|
49
|
+
/** Levels the latest `popstate` still asks to dismiss, one commit at a time. */
|
|
50
|
+
private pendingPops = 0;
|
|
51
|
+
/** `popstate` events to ignore: the ones our own `history.go` causes. */
|
|
52
|
+
private ignorePops = 0;
|
|
53
|
+
/**
|
|
54
|
+
* The depth our own `history.go` is travelling to while its `popstate` is
|
|
55
|
+
* still on its way (`go` is asynchronous): commits meanwhile compare
|
|
56
|
+
* against it, so a second commit does not go back a second time.
|
|
57
|
+
*/
|
|
58
|
+
private movingTo: number | null = null;
|
|
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;
|
|
62
|
+
private enabled = false;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
private readonly renderer: Renderer,
|
|
66
|
+
private readonly root: HTMLElement,
|
|
67
|
+
win: Window = window,
|
|
68
|
+
) {
|
|
69
|
+
this.win = win;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Marks the current entry as the base and starts mirroring pushes and pops. */
|
|
73
|
+
enable(): void {
|
|
74
|
+
if (this.enabled) return;
|
|
75
|
+
this.enabled = true;
|
|
76
|
+
const current = stateOf(this.win.history.state);
|
|
77
|
+
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
|
|
78
|
+
this.renderer.interaction.nativeBackGesture = true;
|
|
79
|
+
this.root.addEventListener('sb:commit', this.onCommit);
|
|
80
|
+
this.win.addEventListener('popstate', this.onPopState);
|
|
81
|
+
this.sync();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dispose(): void {
|
|
85
|
+
if (!this.enabled) return;
|
|
86
|
+
this.enabled = false;
|
|
87
|
+
this.renderer.interaction.nativeBackGesture = false;
|
|
88
|
+
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
89
|
+
this.movingTo = null;
|
|
90
|
+
this.root.removeEventListener('sb:commit', this.onCommit);
|
|
91
|
+
this.win.removeEventListener('popstate', this.onPopState);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
|
|
95
|
+
depth(): number {
|
|
96
|
+
const sheets = this.sheets();
|
|
97
|
+
const top = sheets.at(-1) ?? this.root;
|
|
98
|
+
let depth = sheets.length;
|
|
99
|
+
for (const stack of this.stacks(top)) depth += this.topDepth(stack);
|
|
100
|
+
return depth;
|
|
101
|
+
}
|
|
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
|
+
|
|
133
|
+
private sheets(): HTMLElement[] {
|
|
134
|
+
return [...this.root.querySelectorAll<HTMLElement>(':scope > .sb-sheet')].filter((sheet) => shown(this.win, sheet));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The visible navigation stacks within `container`, not those inside a deeper sheet. */
|
|
138
|
+
private stacks(container: HTMLElement): HTMLElement[] {
|
|
139
|
+
return [...container.querySelectorAll<HTMLElement>('.sb-navstack')].filter((stack) => shown(this.win, stack) && stack.closest('.sb-sheet') === (container.classList.contains('sb-sheet') ? container : null));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private topScreen(stack: HTMLElement): HTMLElement | null {
|
|
143
|
+
const screens = stack.querySelectorAll<HTMLElement>(':scope > .sb-slot > .sb-navscreen');
|
|
144
|
+
return screens.length ? screens[screens.length - 1]! : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private topDepth(stack: HTMLElement): number {
|
|
148
|
+
const top = this.topScreen(stack);
|
|
149
|
+
return top ? Number(top.dataset['sbDepth'] ?? '0') || 0 : 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
|
|
153
|
+
private dismissOne(): boolean {
|
|
154
|
+
const sheets = this.sheets();
|
|
155
|
+
const top = sheets.at(-1) ?? this.root;
|
|
156
|
+
const stacks = this.stacks(top)
|
|
157
|
+
.map((stack) => ({ stack, depth: this.topDepth(stack) }))
|
|
158
|
+
.filter((s) => s.depth > 0)
|
|
159
|
+
.sort((a, b) => b.depth - a.depth);
|
|
160
|
+
const deepest = stacks[0];
|
|
161
|
+
if (deepest) {
|
|
162
|
+
const screen = this.topScreen(deepest.stack);
|
|
163
|
+
const id = Number(screen?.dataset['sbId']);
|
|
164
|
+
if (!Number.isInteger(id)) return false;
|
|
165
|
+
this.renderer.tap(id); // what the back button sends
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
const sheet = sheets.at(-1);
|
|
169
|
+
const id = Number(sheet?.dataset['sbId']);
|
|
170
|
+
if (!Number.isInteger(id)) return false;
|
|
171
|
+
this.renderer.tap(id); // a dismiss request, as the scrim's tap
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private recorded(): number {
|
|
176
|
+
return this.movingTo ?? stateOf(this.win.history.state)?.sbDepth ?? 0;
|
|
177
|
+
}
|
|
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
|
+
|
|
187
|
+
/** Our own `history.go`, remembered until its `popstate` lands (or a moment passes without one: a `go` past the ends fires none). */
|
|
188
|
+
private go(delta: number, to: number): void {
|
|
189
|
+
this.ignorePops += 1;
|
|
190
|
+
this.movingTo = to;
|
|
191
|
+
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
192
|
+
this.movingTimer = setTimeout(() => {
|
|
193
|
+
if (this.movingTo === null) return;
|
|
194
|
+
this.movingTo = null;
|
|
195
|
+
this.ignorePops = Math.max(0, this.ignorePops - 1);
|
|
196
|
+
}, 1000);
|
|
197
|
+
this.win.history.go(delta);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private arrived(): void {
|
|
201
|
+
this.movingTo = null;
|
|
202
|
+
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
203
|
+
this.movingTimer = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private readonly onCommit = (): void => this.sync();
|
|
207
|
+
|
|
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. */
|
|
209
|
+
sync(): void {
|
|
210
|
+
if (!this.enabled) return;
|
|
211
|
+
const depth = this.depth();
|
|
212
|
+
const recorded = this.recorded();
|
|
213
|
+
const selection = this.selection();
|
|
214
|
+
if (this.pendingPops > 0) {
|
|
215
|
+
// Dismissing for a `popstate`: the entry already names the target depth.
|
|
216
|
+
if (depth > recorded) {
|
|
217
|
+
if (!this.dismissOne()) this.pendingPops = 0;
|
|
218
|
+
else this.pendingPops -= 1;
|
|
219
|
+
} else this.pendingPops = 0;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (depth > recorded) {
|
|
223
|
+
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
|
|
224
|
+
} else if (depth < recorded) {
|
|
225
|
+
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
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), '');
|
|
236
|
+
}
|
|
237
|
+
this.restoringSelection = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private readonly onPopState = (event: PopStateEvent): void => {
|
|
241
|
+
if (this.ignorePops > 0) {
|
|
242
|
+
this.ignorePops -= 1;
|
|
243
|
+
this.arrived();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const target = stateOf(event.state);
|
|
247
|
+
if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
|
|
248
|
+
const depth = this.depth();
|
|
249
|
+
if (target.sbDepth < depth) {
|
|
250
|
+
// Back: dismiss one level now, the rest as each commit lands.
|
|
251
|
+
this.pendingPops = depth - target.sbDepth;
|
|
252
|
+
if (!this.dismissOne()) this.pendingPops = 0;
|
|
253
|
+
else this.pendingPops -= 1;
|
|
254
|
+
} else if (target.sbDepth > depth) {
|
|
255
|
+
// Forward: a screen cannot be re-pushed; return to the entry that matches.
|
|
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);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
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/interaction.ts
CHANGED
|
@@ -202,6 +202,14 @@ export class InteractionController {
|
|
|
202
202
|
private session: Session | null = null;
|
|
203
203
|
private openSwipe: OpenSwipe | null = null;
|
|
204
204
|
private swipeBack: SwipeBackState | null = null;
|
|
205
|
+
/**
|
|
206
|
+
* The browser's own back gesture is in charge (browserHistory.ts mirrors
|
|
207
|
+
* navigation in the history): the renderer's edge swipe then ignores touch
|
|
208
|
+
* and pen, whose edge swipe the browser turns into a `popstate` that pops,
|
|
209
|
+
* so running both would pop twice. A mouse drag from the edge still pops
|
|
210
|
+
* here: no browser navigates on one.
|
|
211
|
+
*/
|
|
212
|
+
nativeBackGesture = false;
|
|
205
213
|
/** Wheel-driven swipe of a row: the host, its live offset and the idle timer that settles it. */
|
|
206
214
|
private wheelSwipe: { host: SwipeHost; offset: number; timer: number } | null = null;
|
|
207
215
|
/** Wheel-driven pull at the top of a refreshable scroller. */
|
|
@@ -426,7 +434,8 @@ export class InteractionController {
|
|
|
426
434
|
}
|
|
427
435
|
const byPress = event.pointerType !== 'mouse';
|
|
428
436
|
const screen = target.closest<HTMLElement>('.sb-navscreen');
|
|
429
|
-
|
|
437
|
+
const browserSwipes = this.nativeBackGesture && event.pointerType !== 'mouse';
|
|
438
|
+
if (!browserSwipes && screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
|
|
430
439
|
const rect = screen.getBoundingClientRect();
|
|
431
440
|
const id = Number(screen.dataset[ID_ATTR]);
|
|
432
441
|
if (event.clientX - rect.left <= SWIPE_BACK_EDGE && Number.isInteger(id)) {
|