@swiftbrowser/web 0.9.3 → 0.9.4

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 CHANGED
@@ -292,6 +292,25 @@ remains for the landing page and for `/app/` without a parameter. In the
292
292
  `/manifest.webmanifest` is the app's, and the page is titled by the app's
293
293
  display name alone.
294
294
 
295
+ ## Browser history
296
+
297
+ `src/browserHistory.ts` mirrors the app's navigation in the browser's history
298
+ on the app page: pushing a screen or presenting a sheet adds an entry, and the
299
+ browser's back (a phone's edge swipe, the toolbar button, Alt+Left) pops or
300
+ dismisses it through the same `tap` the bar's back button and the sheet's
301
+ scrim send, so Swift's state stays the source of truth. A pop the app makes
302
+ itself takes the history back with it, and forward returns to the entry that
303
+ matches rather than re-pushing. Depth is read from the DOM after every
304
+ `commit`: the presented sheets plus the pushed screens of each visible stack
305
+ (the selected tab's, both columns of a split view, the top sheet's). The URL
306
+ is left alone. While it is on, the renderer's own edge swipe ignores touch and
307
+ pen: the browser's edge swipe is the swipe back, and it arrives as a
308
+ `popstate` (a mouse drag from the edge still pops through the renderer).
309
+ Its own `history.go` after a pop the app made itself is remembered until the
310
+ `popstate` lands, so commits meanwhile do not go back again.
311
+ An island has it on by default and opts out with `mount(..., { history: false })`
312
+ (`<SwiftUIView history={false}>` in @swiftbrowser/react).
313
+
295
314
  ## Deploying (Cloudflare Pages)
296
315
 
297
316
  `npm run build` writes a static site to `dist/`: `index.html`, `app/index.html`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -0,0 +1,199 @@
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
+ * While enabled, the renderer's own edge swipe to pop is off for touch and
17
+ * pen: the browser's edge swipe (Safari's, Android's back gesture) is the
18
+ * swipe back, and it lands here as a `popstate`. A mouse drag from the edge
19
+ * still pops through the renderer, since no browser turns one into history.
20
+ */
21
+ import type { Renderer } from './renderer';
22
+
23
+ interface HistoryState {
24
+ /** Our marker; entries without it belong to the page or the app. */
25
+ sbHistory: true;
26
+ /** How many levels (screens and sheets) are above the base in this entry. */
27
+ sbDepth: number;
28
+ }
29
+
30
+ /** What the renderer's elements need to say for this to read them (`sbId`, `sbDepth` datasets). */
31
+ const stateOf = (value: unknown): HistoryState | null => (typeof value === 'object' && value !== null && (value as HistoryState).sbHistory === true ? (value as HistoryState) : null);
32
+
33
+ function shown(win: Window, el: HTMLElement): boolean {
34
+ if (el.hidden || el.getClientRects().length === 0) return false;
35
+ return win.getComputedStyle(el).visibility !== 'hidden';
36
+ }
37
+
38
+ export class BrowserHistory {
39
+ private readonly win: Window;
40
+ /** Levels the latest `popstate` still asks to dismiss, one commit at a time. */
41
+ private pendingPops = 0;
42
+ /** `popstate` events to ignore: the ones our own `history.go` causes. */
43
+ private ignorePops = 0;
44
+ /**
45
+ * The depth our own `history.go` is travelling to while its `popstate` is
46
+ * still on its way (`go` is asynchronous): commits meanwhile compare
47
+ * against it, so a second commit does not go back a second time.
48
+ */
49
+ private movingTo: number | null = null;
50
+ private movingTimer: ReturnType<typeof setTimeout> | null = null;
51
+ private enabled = false;
52
+
53
+ constructor(
54
+ private readonly renderer: Renderer,
55
+ private readonly root: HTMLElement,
56
+ win: Window = window,
57
+ ) {
58
+ this.win = win;
59
+ }
60
+
61
+ /** Marks the current entry as the base and starts mirroring pushes and pops. */
62
+ enable(): void {
63
+ if (this.enabled) return;
64
+ this.enabled = true;
65
+ const current = stateOf(this.win.history.state);
66
+ if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), sbHistory: true, sbDepth: 0 }, '');
67
+ this.renderer.interaction.nativeBackGesture = true;
68
+ this.root.addEventListener('sb:commit', this.onCommit);
69
+ this.win.addEventListener('popstate', this.onPopState);
70
+ this.sync();
71
+ }
72
+
73
+ dispose(): void {
74
+ if (!this.enabled) return;
75
+ this.enabled = false;
76
+ this.renderer.interaction.nativeBackGesture = false;
77
+ if (this.movingTimer) clearTimeout(this.movingTimer);
78
+ this.movingTo = null;
79
+ this.root.removeEventListener('sb:commit', this.onCommit);
80
+ this.win.removeEventListener('popstate', this.onPopState);
81
+ }
82
+
83
+ /** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
84
+ depth(): number {
85
+ const sheets = this.sheets();
86
+ const top = sheets.at(-1) ?? this.root;
87
+ let depth = sheets.length;
88
+ for (const stack of this.stacks(top)) depth += this.topDepth(stack);
89
+ return depth;
90
+ }
91
+
92
+ private sheets(): HTMLElement[] {
93
+ return [...this.root.querySelectorAll<HTMLElement>(':scope > .sb-sheet')].filter((sheet) => shown(this.win, sheet));
94
+ }
95
+
96
+ /** The visible navigation stacks within `container`, not those inside a deeper sheet. */
97
+ private stacks(container: HTMLElement): HTMLElement[] {
98
+ return [...container.querySelectorAll<HTMLElement>('.sb-navstack')].filter((stack) => shown(this.win, stack) && stack.closest('.sb-sheet') === (container.classList.contains('sb-sheet') ? container : null));
99
+ }
100
+
101
+ private topScreen(stack: HTMLElement): HTMLElement | null {
102
+ const screens = stack.querySelectorAll<HTMLElement>(':scope > .sb-slot > .sb-navscreen');
103
+ return screens.length ? screens[screens.length - 1]! : null;
104
+ }
105
+
106
+ private topDepth(stack: HTMLElement): number {
107
+ const top = this.topScreen(stack);
108
+ return top ? Number(top.dataset['sbDepth'] ?? '0') || 0 : 0;
109
+ }
110
+
111
+ /** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
112
+ private dismissOne(): boolean {
113
+ const sheets = this.sheets();
114
+ const top = sheets.at(-1) ?? this.root;
115
+ const stacks = this.stacks(top)
116
+ .map((stack) => ({ stack, depth: this.topDepth(stack) }))
117
+ .filter((s) => s.depth > 0)
118
+ .sort((a, b) => b.depth - a.depth);
119
+ const deepest = stacks[0];
120
+ if (deepest) {
121
+ const screen = this.topScreen(deepest.stack);
122
+ const id = Number(screen?.dataset['sbId']);
123
+ if (!Number.isInteger(id)) return false;
124
+ this.renderer.tap(id); // what the back button sends
125
+ return true;
126
+ }
127
+ const sheet = sheets.at(-1);
128
+ const id = Number(sheet?.dataset['sbId']);
129
+ if (!Number.isInteger(id)) return false;
130
+ this.renderer.tap(id); // a dismiss request, as the scrim's tap
131
+ return true;
132
+ }
133
+
134
+ private recorded(): number {
135
+ return this.movingTo ?? stateOf(this.win.history.state)?.sbDepth ?? 0;
136
+ }
137
+
138
+ /** Our own `history.go`, remembered until its `popstate` lands (or a moment passes without one: a `go` past the ends fires none). */
139
+ private go(delta: number, to: number): void {
140
+ this.ignorePops += 1;
141
+ this.movingTo = to;
142
+ if (this.movingTimer) clearTimeout(this.movingTimer);
143
+ this.movingTimer = setTimeout(() => {
144
+ if (this.movingTo === null) return;
145
+ this.movingTo = null;
146
+ this.ignorePops = Math.max(0, this.ignorePops - 1);
147
+ }, 1000);
148
+ this.win.history.go(delta);
149
+ }
150
+
151
+ private arrived(): void {
152
+ this.movingTo = null;
153
+ if (this.movingTimer) clearTimeout(this.movingTimer);
154
+ this.movingTimer = null;
155
+ }
156
+
157
+ private readonly onCommit = (): void => this.sync();
158
+
159
+ /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them. */
160
+ sync(): void {
161
+ if (!this.enabled) return;
162
+ const depth = this.depth();
163
+ const recorded = this.recorded();
164
+ if (this.pendingPops > 0) {
165
+ // Dismissing for a `popstate`: the entry already names the target depth.
166
+ if (depth > recorded) {
167
+ if (!this.dismissOne()) this.pendingPops = 0;
168
+ else this.pendingPops -= 1;
169
+ } else this.pendingPops = 0;
170
+ return;
171
+ }
172
+ if (depth > recorded) {
173
+ for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState({ sbHistory: true, sbDepth: level } satisfies HistoryState, '');
174
+ } else if (depth < recorded) {
175
+ // The app popped or dismissed on its own (back button, swipe, code): leave those entries.
176
+ this.go(depth - recorded, depth);
177
+ }
178
+ }
179
+
180
+ private readonly onPopState = (event: PopStateEvent): void => {
181
+ if (this.ignorePops > 0) {
182
+ this.ignorePops -= 1;
183
+ this.arrived();
184
+ return;
185
+ }
186
+ const target = stateOf(event.state);
187
+ if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
188
+ const depth = this.depth();
189
+ if (target.sbDepth < depth) {
190
+ // Back: dismiss one level now, the rest as each commit lands.
191
+ this.pendingPops = depth - target.sbDepth;
192
+ if (!this.dismissOne()) this.pendingPops = 0;
193
+ else this.pendingPops -= 1;
194
+ } else if (target.sbDepth > depth) {
195
+ // Forward: a screen cannot be re-pushed; return to the entry that matches.
196
+ this.go(depth - target.sbDepth, depth);
197
+ }
198
+ };
199
+ }
@@ -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
- if (screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
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)) {
package/src/main.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type RendererEvent,
15
15
  type ScenePhase,
16
16
  } from './protocol';
17
+ import { BrowserHistory } from './browserHistory';
17
18
  import { Renderer, RendererError } from './renderer';
18
19
  import { withBlockedHosts } from './blockedHosts';
19
20
  import { withCORSProxy } from './corsProxy';
@@ -97,6 +98,9 @@ function dispatch(event: RendererEvent): void {
97
98
 
98
99
  const renderer = new Renderer(screen, dispatch);
99
100
 
101
+ // The browser's back pops the app's screens and dismisses its sheets (browserHistory.ts).
102
+ new BrowserHistory(renderer, screen).enable();
103
+
100
104
  // On a phone the browser's status bar strip and safe areas take the color the
101
105
  // app draws at its top edge (chromeColor.ts).
102
106
  const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
package/src/renderer.ts CHANGED
@@ -1242,6 +1242,15 @@ export class Renderer {
1242
1242
  /** Called after each layout pass (main.ts keeps the browser chrome's color in step). */
1243
1243
  afterLayout: (() => void) | null = null;
1244
1244
 
1245
+ /**
1246
+ * Sends a tap for an element, as a click on it would: a `navscreen` pops
1247
+ * it (the back button's tap), a `sheet` asks to be dismissed (the scrim's).
1248
+ * For the page's own controls, such as the browser's history (browserHistory.ts).
1249
+ */
1250
+ tap(id: ElementId): void {
1251
+ this.onEvent({ type: 'tap', id });
1252
+ }
1253
+
1245
1254
  /**
1246
1255
  * Run the layout engine over the current tree and position every element.
1247
1256
  * Returns the elements whose box changed. Runs at every `commit`; call it