@swiftbrowser/web 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
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": {
@@ -61,6 +61,16 @@ function shown(win: Window, el: HTMLElement): boolean {
61
61
  return win.getComputedStyle(el).visibility !== 'hidden';
62
62
  }
63
63
 
64
+ /**
65
+ * A back the browser makes after a touch this close to a side of the page is
66
+ * its edge swipe (Safari's, Android's back gesture), which has animated the
67
+ * pop already: the renderer then does not slide the screen out again. The
68
+ * browser's back button sends no touch to the page, so its pop slides.
69
+ */
70
+ const SWIPE_EDGE_PX = 44;
71
+ /** How long after such a touch a back still counts as the swipe it started. */
72
+ const SWIPE_TOUCH_MS = 2000;
73
+
64
74
  /** When `sync` runs: the renderer's `sb:precommit` (the pass's ops applied, not yet laid out), its `sb:commit`, or `enable`. */
65
75
  export type SyncStage = 'precommit' | 'commit' | 'enabling';
66
76
 
@@ -76,7 +86,9 @@ export class BrowserHistory {
76
86
  * split view clears its sidebar selection first, and the detail goes on
77
87
  * the next), so a commit that still shows the level is not a new one.
78
88
  */
79
- private dismissing: { target: number; from: number; until: number } | null = null;
89
+ private dismissing: { target: number; from: number; until: number; instant: boolean } | null = null;
90
+ /** When a touch last began at a side of the page (`SWIPE_EDGE_PX`): the start of the browser's edge swipe. */
91
+ private lastEdgeTouch = Number.NEGATIVE_INFINITY;
80
92
  /** `popstate` events to ignore: the ones our own `history.go` causes. */
81
93
  private ignorePops = 0;
82
94
  /**
@@ -116,6 +128,7 @@ export class BrowserHistory {
116
128
  this.root.addEventListener('sb:precommit', this.onPreCommit);
117
129
  this.root.addEventListener('sb:commit', this.onCommit);
118
130
  this.win.addEventListener('popstate', this.onPopState);
131
+ this.win.addEventListener('touchstart', this.onTouchStart, { capture: true, passive: true });
119
132
  // Enabled before the first commit (a mount, or a remount such as React's
120
133
  // StrictMode makes in development) the screen is not drawn yet: fewer
121
134
  // levels than the entry records is not the app popping, so only the
@@ -135,6 +148,7 @@ export class BrowserHistory {
135
148
  this.root.removeEventListener('sb:precommit', this.onPreCommit);
136
149
  this.root.removeEventListener('sb:commit', this.onCommit);
137
150
  this.win.removeEventListener('popstate', this.onPopState);
151
+ this.win.removeEventListener('touchstart', this.onTouchStart, { capture: true });
138
152
  }
139
153
 
140
154
  /**
@@ -232,11 +246,15 @@ export class BrowserHistory {
232
246
  if (!dismissing) return;
233
247
  dismissing.from = depth;
234
248
  dismissing.until = performance.now() + DISMISS_WAIT_MS;
235
- if (!this.dismissOne()) this.dismissing = null;
249
+ if (!this.dismissOne(dismissing.instant)) this.dismissing = null;
236
250
  }
237
251
 
238
- /** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
239
- private dismissOne(): boolean {
252
+ /**
253
+ * Asks the app to dismiss one level: the top sheet when nothing is pushed
254
+ * inside it, else the deepest visible stack's top screen. `instant`: the
255
+ * browser has animated the pop (its swipe), so the renderer does not.
256
+ */
257
+ private dismissOne(instant: boolean): boolean {
240
258
  const sheets = this.sheets();
241
259
  const top = sheets.at(-1) ?? this.root;
242
260
  const stacks = this.stacks(top)
@@ -248,6 +266,7 @@ export class BrowserHistory {
248
266
  const screen = this.topScreen(deepest.stack);
249
267
  const id = Number(screen?.dataset['sbId']);
250
268
  if (!Number.isInteger(id)) return false;
269
+ if (instant) this.renderer.popWithoutAnimation(id);
251
270
  this.renderer.tap(id); // what the back button sends
252
271
  return true;
253
272
  }
@@ -364,6 +383,12 @@ export class BrowserHistory {
364
383
  this.restoringSelection = false;
365
384
  }
366
385
 
386
+ private readonly onTouchStart = (event: TouchEvent): void => {
387
+ const touch = event.touches[0];
388
+ if (!touch) return;
389
+ if (touch.clientX <= SWIPE_EDGE_PX || touch.clientX >= this.win.innerWidth - SWIPE_EDGE_PX) this.lastEdgeTouch = performance.now();
390
+ };
391
+
367
392
  private readonly onPopState = (event: PopStateEvent): void => {
368
393
  if (this.ignorePops > 0) {
369
394
  this.ignorePops -= 1;
@@ -378,8 +403,10 @@ export class BrowserHistory {
378
403
  // A move the browser makes replaces any back still in progress.
379
404
  this.dismissing = null;
380
405
  if (target.sbDepth < depth) {
381
- // Back: dismiss one level now, the rest as each pop lands.
382
- this.dismissing = { target: target.sbDepth, from: depth, until: 0 };
406
+ // Back: dismiss one level now, the rest as each pop lands. After a
407
+ // touch at a side of the page it is the browser's swipe, animated already.
408
+ const instant = performance.now() - this.lastEdgeTouch < SWIPE_TOUCH_MS;
409
+ this.dismissing = { target: target.sbDepth, from: depth, until: 0, instant };
383
410
  this.dismissFrom(depth);
384
411
  } else if (target.sbDepth > depth) {
385
412
  // Forward: a screen cannot be re-pushed; return to the entry that matches.
package/src/renderer.ts CHANGED
@@ -1310,6 +1310,24 @@ export class Renderer {
1310
1310
  this.onEvent({ type: 'tap', id });
1311
1311
  }
1312
1312
 
1313
+ /**
1314
+ * Screens the browser's own history move pops (`popWithoutAnimation`):
1315
+ * the browser has animated the pop already, so the renderer does not.
1316
+ */
1317
+ private instantPops = new Set<ElementId>();
1318
+
1319
+ /**
1320
+ * The next removal of screen `id` is a pop the browser has already shown
1321
+ * (its back swipe or button, `browserHistory.ts`): the screen goes without
1322
+ * the slide, and the screen it uncovers takes its place at once rather
1323
+ * than sliding back, so the pop does not play a second time.
1324
+ */
1325
+ popWithoutAnimation(id: ElementId): void {
1326
+ this.instantPops.add(id);
1327
+ // A pop the app refused leaves nothing to skip for long.
1328
+ window.setTimeout(() => this.instantPops.delete(id), NAV_TRANSITION_MS * 5);
1329
+ }
1330
+
1313
1331
  /** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
1314
1332
  private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
1315
1333
 
@@ -2221,7 +2239,8 @@ export class Renderer {
2221
2239
  // screen (`--sb-nav-from`), and the screen below is handed back to the
2222
2240
  // stylesheet so it finishes its slide to 0 as the new top screen.
2223
2241
  this.interaction.screenRemoved(el);
2224
- animatePop(stack, el);
2242
+ if (this.instantPops.delete(id)) settleWithoutTransition(stack);
2243
+ else animatePop(stack, el);
2225
2244
  }
2226
2245
  const isSheet = el.classList.contains('sb-sheet');
2227
2246
  if (isSheet && parent) animateSheetOut(parent, el);
@@ -3043,6 +3062,16 @@ function animatePush(screen: HTMLElement): void {
3043
3062
  * visual clone (stripped of element ids so it is invisible to the renderer)
3044
3063
  * slides away on top of the stack and is dropped afterwards.
3045
3064
  */
3065
+ /**
3066
+ * A pop the browser has already animated: the stack's screens take their new
3067
+ * places without a transition (styles.css, `data-sb-instant`) for the frames
3068
+ * the change lands in.
3069
+ */
3070
+ function settleWithoutTransition(stack: HTMLElement): void {
3071
+ stack.dataset['sbInstant'] = '';
3072
+ requestAnimationFrame(() => requestAnimationFrame(() => delete stack.dataset['sbInstant']));
3073
+ }
3074
+
3046
3075
  function animatePop(stack: HTMLElement, screen: HTMLElement): void {
3047
3076
  const clone = ghostClone(screen);
3048
3077
  clone.removeAttribute('inert');
package/src/styles.css CHANGED
@@ -1065,6 +1065,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1065
1065
  animation: sb-nav-push 0.4s var(--sb-nav-easing) both;
1066
1066
  }
1067
1067
 
1068
+ /* A pop the browser has animated already (its back swipe, renderer.ts
1069
+ popWithoutAnimation): the uncovered screen takes its place at once. */
1070
+ .sb-navstack[data-sb-instant] > .sb-slot > .sb-navscreen,
1071
+ .sb-navstack[data-sb-instant] > .sb-slot > .sb-navscreen::after {
1072
+ transition: none !important;
1073
+ }
1074
+
1068
1075
  .sb-navstack > .sb-navscreen--pop {
1069
1076
  z-index: 2;
1070
1077
  pointer-events: none;