@swiftbrowser/web 0.10.2 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.10.2",
3
+ "version": "0.10.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": {
package/src/animation.ts CHANGED
@@ -961,12 +961,36 @@ export class Animator {
961
961
  el.animate([from, to], { ...timing, fill: 'backwards' });
962
962
  }
963
963
 
964
+ /** The exit clones playing, each with the element it left (its parent at removal): `dropExitClonesIn`. */
965
+ private readonly exitClones = new Map<HTMLElement, HTMLElement>();
966
+
967
+ /**
968
+ * Drops the exit clones of elements that left `container` or anything in
969
+ * it: a clone stands in for content of a container that is now going
970
+ * itself, and would play on over whatever takes the container's place. A
971
+ * screen that swaps its content for a placeholder a commit before it is
972
+ * popped (a split view's detail as its selection clears) left its bar
973
+ * group's clone fading over the uncovered screen's bar. (A clone not built
974
+ * yet, its pass still waiting for the frame, is refused in `exit`.)
975
+ */
976
+ dropExitClonesIn(container: HTMLElement): void {
977
+ for (const [clone, owner] of this.exitClones) {
978
+ if (owner !== container && !container.contains(owner)) continue;
979
+ this.exitClones.delete(clone);
980
+ for (const animation of clone.getAnimations()) animation.cancel();
981
+ clone.remove();
982
+ }
983
+ }
984
+
964
985
  /**
965
986
  * Re-insert a visual clone of a removed element at its old position and
966
987
  * play its transition backwards; the clone is dropped when done.
967
988
  */
968
989
  exit(rec: RemovedRecord, animation: Animation | undefined): HTMLElement | null {
969
990
  const parent = rec.ancestors[0] ?? null;
991
+ // The element it left has gone itself since (a later pass in the same
992
+ // task popped the screen): nothing is left to leave from.
993
+ if (parent && !parent.isConnected) return null;
970
994
  const end = transitionStart(rec.transition ?? implicitTransitionOf(rec.el, parent), true);
971
995
  if (isIdentity(end) || !rec.rect) return null;
972
996
  const clone = ghostClone(rec.el);
@@ -999,7 +1023,11 @@ export class Animator {
999
1023
  }
1000
1024
  const timing = baseTiming(timingFor(animation));
1001
1025
  const anim = clone.animate([from, to], { ...timing, fill: 'forwards' });
1002
- const done = (): void => clone.remove();
1026
+ if (parent) this.exitClones.set(clone, parent);
1027
+ const done = (): void => {
1028
+ this.exitClones.delete(clone);
1029
+ clone.remove();
1030
+ };
1003
1031
  anim.finished.then(done, done);
1004
1032
  window.setTimeout(done, timing.duration + timing.delay + 100);
1005
1033
  return clone;
@@ -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
 
@@ -2213,6 +2231,8 @@ export class Renderer {
2213
2231
  const el = this.elements.get(id);
2214
2232
  if (!el) throw new RendererError(`Cannot remove unknown element ${id}`, op);
2215
2233
  const parent = el.parentElement;
2234
+ // Clones of what left this subtree earlier (a bar group's, src/animation.ts) go with it.
2235
+ this.animator.dropExitClonesIn(el);
2216
2236
  const stack = parent?.classList.contains('sb-navstack-screens')
2217
2237
  ? parent.parentElement
2218
2238
  : null;
@@ -2221,7 +2241,8 @@ export class Renderer {
2221
2241
  // screen (`--sb-nav-from`), and the screen below is handed back to the
2222
2242
  // stylesheet so it finishes its slide to 0 as the new top screen.
2223
2243
  this.interaction.screenRemoved(el);
2224
- animatePop(stack, el);
2244
+ if (this.instantPops.delete(id)) settleWithoutTransition(stack);
2245
+ else animatePop(stack, el);
2225
2246
  }
2226
2247
  const isSheet = el.classList.contains('sb-sheet');
2227
2248
  if (isSheet && parent) animateSheetOut(parent, el);
@@ -3043,6 +3064,16 @@ function animatePush(screen: HTMLElement): void {
3043
3064
  * visual clone (stripped of element ids so it is invisible to the renderer)
3044
3065
  * slides away on top of the stack and is dropped afterwards.
3045
3066
  */
3067
+ /**
3068
+ * A pop the browser has already animated: the stack's screens take their new
3069
+ * places without a transition (styles.css, `data-sb-instant`) for the frames
3070
+ * the change lands in.
3071
+ */
3072
+ function settleWithoutTransition(stack: HTMLElement): void {
3073
+ stack.dataset['sbInstant'] = '';
3074
+ requestAnimationFrame(() => requestAnimationFrame(() => delete stack.dataset['sbInstant']));
3075
+ }
3076
+
3046
3077
  function animatePop(stack: HTMLElement, screen: HTMLElement): void {
3047
3078
  const clone = ghostClone(screen);
3048
3079
  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;