@swiftbrowser/web 0.9.11 → 0.9.12

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.9.11",
3
+ "version": "0.9.12",
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/renderer.ts CHANGED
@@ -1808,10 +1808,19 @@ export class Renderer {
1808
1808
  .map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
1809
1809
  .filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
1810
1810
  .sort((a, b) => a.command.serial - b.command.serial);
1811
+ // A page a finger set running gives way to the app's commands; a drag in progress carries on across them.
1812
+ const fresh = commands.some(({ command }) => command.serial > this.scrollSerial);
1813
+ if (fresh) this.touchPaging.beforeScrollCommands();
1811
1814
  for (const { id, command } of commands) {
1812
1815
  if (command.serial <= this.scrollSerial) continue;
1813
1816
  scrollToTarget(this, result, id, command, animated);
1817
+ // The scroll views it moved report their position afresh when they next settle, even one they
1818
+ // reported last: the app set its binding itself, and where the view rests may not be it.
1819
+ for (let scroller = this.elements.get(id)?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list'); scroller; scroller = scroller.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null) {
1820
+ this.reportedPositions.delete(scroller);
1821
+ }
1814
1822
  }
1823
+ if (fresh) this.touchPaging.afterScrollCommands();
1815
1824
  for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
1816
1825
  }
1817
1826
 
package/src/styles.css CHANGED
@@ -751,6 +751,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
751
751
  touch-action: pan-x pinch-zoom;
752
752
  }
753
753
 
754
+ /* Nothing inside widens the pans the browser may make along the paging axis:
755
+ a button's `manipulation` would let Safari pan the view itself under a
756
+ quick swipe that starts on it. */
757
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="horizontal"] * {
758
+ touch-action: pan-y pinch-zoom;
759
+ }
760
+
761
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="vertical"] * {
762
+ touch-action: pan-x pinch-zoom;
763
+ }
764
+
754
765
  .sb-scroll-content,
755
766
  .sb-list-content {
756
767
  position: relative;
@@ -18,8 +18,14 @@ export const FLICK_VELOCITY = 0.3;
18
18
  export const FLICK_WINDOW_MS = 100;
19
19
  /** How far a flick carries on inside a page longer than the viewport (its velocity times this). */
20
20
  export const FLICK_RUN_MS = 250;
21
- /** A touch is a drag once it moved this far; the larger axis then decides whose gesture it is. */
22
- export const DIRECTION_SLOP = 6;
21
+ /**
22
+ * A touch is a drag once it moved this far; the larger axis of that first
23
+ * move decides whose gesture it is. Small, and under Safari's own threshold
24
+ * for starting a pan: a move left to the browser lets it begin a pan, after
25
+ * which it cancels the touch for the page, and a drag of ours would end in
26
+ * a run from wherever the finger was.
27
+ */
28
+ export const DIRECTION_SLOP = 3;
23
29
 
24
30
  /** Where a scroll view may rest for one page: at its start, or anywhere a page longer than the viewport fills it (`start === end` for a page the viewport's size). */
25
31
  export interface RestInterval {
@@ -120,19 +126,37 @@ interface Gesture {
120
126
  /** The finger position (along the axis) where our drag took over. */
121
127
  fromPos: number;
122
128
  samples: TouchSample[];
129
+ /** The page under the leading edge as of the last move, to find again after the app's scroll commands. */
130
+ page: Page | null;
123
131
  }
124
132
 
125
133
  interface Run {
126
134
  el: HTMLElement;
127
135
  frame: number;
128
- /** The offset the last frame wrote: another writer moving the view ends the run. */
129
- written: number;
136
+ }
137
+
138
+ /** Where a drag was when the app's scroll commands began: the offset, and the page under it to find again after. */
139
+ interface Held {
140
+ el: HTMLElement;
141
+ offset: number;
142
+ page: Page | null;
130
143
  }
131
144
 
132
145
  const PAGING_SELECTOR = '.sb-scrollview[data-sb-snap="paging"]';
133
146
 
147
+ export interface Page {
148
+ element: Element;
149
+ start: number;
150
+ length: number;
151
+ }
152
+
153
+ /** The page whose extent holds `offset` (the leading edge), if any. */
154
+ function pageAt(el: HTMLElement, horizontal: boolean, offset: number): Page | null {
155
+ return pagesOf(el, horizontal).find((p) => p.start <= offset + 1 && offset < p.start + p.length) ?? null;
156
+ }
157
+
134
158
  /** Pages of a paging scroll view in scroll coordinates along its axis: the children of its `scrollTargetLayout` stack, else of its content. */
135
- export function pagesOf(el: HTMLElement, horizontal: boolean): { start: number; length: number }[] {
159
+ export function pagesOf(el: HTMLElement, horizontal: boolean): Page[] {
136
160
  const targets = [...el.querySelectorAll<HTMLElement>('[data-sb-scroll-target] > *')].filter((t) => t.parentElement?.closest('.sb-scrollview, .sb-list') === el);
137
161
  const children = targets.length > 0 ? targets : [...((el.firstElementChild?.children ?? []) as Iterable<Element>)];
138
162
  const box = el.getBoundingClientRect();
@@ -141,13 +165,14 @@ export function pagesOf(el: HTMLElement, horizontal: boolean): { start: number;
141
165
  const offset = horizontal ? el.scrollLeft : el.scrollTop;
142
166
  return children.map((child) => {
143
167
  const rect = child.getBoundingClientRect();
144
- return horizontal ? { start: rect.left - box.left - padding + offset, length: rect.width } : { start: rect.top - box.top - padding + offset, length: rect.height };
168
+ return horizontal ? { element: child, start: rect.left - box.left - padding + offset, length: rect.width } : { element: child, start: rect.top - box.top - padding + offset, length: rect.height };
145
169
  });
146
170
  }
147
171
 
148
172
  export class TouchPaging {
149
173
  private gesture: Gesture | null = null;
150
174
  private run: Run | null = null;
175
+ private held: Held | null = null;
151
176
 
152
177
  /**
153
178
  * `onRest(el)`: the view came to rest on a page after a gesture of ours
@@ -159,7 +184,9 @@ export class TouchPaging {
159
184
  private readonly onRest: (el: HTMLElement) => void = () => {},
160
185
  ) {
161
186
  root.addEventListener('touchstart', this.onTouchStart, { passive: true });
162
- root.addEventListener('touchmove', this.onTouchMove, { passive: true });
187
+ // Not passive: a drag of ours cancels the browser's own handling of the moves (Safari pans a
188
+ // scroll view under a finger whatever `touch-action` says, and the two would fight over it).
189
+ root.addEventListener('touchmove', this.onTouchMove, { passive: false });
163
190
  root.addEventListener('touchend', this.onTouchEnd, { passive: true });
164
191
  root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
165
192
  }
@@ -169,6 +196,67 @@ export class TouchPaging {
169
196
  return (this.gesture?.el === el && this.gesture.ours === true) || this.run?.el === el;
170
197
  }
171
198
 
199
+ /**
200
+ * The app is about to scroll (`scrollTo` commands): a run still going
201
+ * gives way to them, and a drag in progress notes where the view is and
202
+ * which page is under the finger, to carry on from there once the
203
+ * commands are done (`afterScrollCommands`).
204
+ */
205
+ beforeScrollCommands(): void {
206
+ const run = this.run;
207
+ if (run) {
208
+ this.stopRun();
209
+ this.onRest(run.el);
210
+ }
211
+ const g = this.gesture;
212
+ this.held = null;
213
+ if (!g?.ours) return;
214
+ // The page as of the drag's last move: the commands' pass has already changed the tree by now.
215
+ this.held = { el: g.el, offset: g.horizontal ? g.el.scrollLeft : g.el.scrollTop, page: g.page };
216
+ }
217
+
218
+ /**
219
+ * The app's scroll commands are done. A drag in progress keeps the
220
+ * content under the finger where it was: an app re-centring its pages
221
+ * on the one arrived at moves the content a page and scrolls the same
222
+ * page, which the command's own offset would show aligned, dropping the
223
+ * drag so far. The page's new places reach the DOM on the next frame
224
+ * (the renderer commits its layout then), so the drag carries on from
225
+ * them at the next frame or the next move, whichever comes first.
226
+ */
227
+ afterScrollCommands(): void {
228
+ const held = this.held;
229
+ if (!held) return;
230
+ const g = this.gesture;
231
+ if (!g?.ours || g.el !== held.el) {
232
+ this.held = null;
233
+ return;
234
+ }
235
+ requestAnimationFrame(() => this.carryOn());
236
+ }
237
+
238
+ /**
239
+ * The page that was under the finger is found again and the offset
240
+ * moved by what it moved; without it (a page that is gone), the drag
241
+ * carries on from the offset the commands set.
242
+ */
243
+ private carryOn(): void {
244
+ const held = this.held;
245
+ this.held = null;
246
+ const g = this.gesture;
247
+ if (!held || !g?.ours || g.el !== held.el) return;
248
+ const now = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
249
+ const page = held.page ? pagesOf(g.el, g.horizontal).find((p) => p.element === held.page!.element) : undefined;
250
+ const shift = page ? page.start - held.page!.start : now - held.offset;
251
+ g.from += shift;
252
+ if (page) {
253
+ const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
254
+ const offset = Math.min(max, Math.max(0, held.offset + shift));
255
+ if (g.horizontal) g.el.scrollLeft = offset;
256
+ else g.el.scrollTop = offset;
257
+ }
258
+ }
259
+
172
260
  dispose(): void {
173
261
  this.root.removeEventListener('touchstart', this.onTouchStart);
174
262
  this.root.removeEventListener('touchmove', this.onTouchMove);
@@ -186,7 +274,7 @@ export class TouchPaging {
186
274
  const axes = el.dataset['sbAxes'];
187
275
  if (axes !== 'horizontal' && axes !== 'vertical') return;
188
276
  const horizontal = axes === 'horizontal';
189
- this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [] };
277
+ this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [], page: null };
190
278
  // A finger on a page still running to its place catches it: the drag is
191
279
  // ours from here, and the lift runs it on to a page.
192
280
  if (this.run?.el === el) {
@@ -202,6 +290,7 @@ export class TouchPaging {
202
290
  const g = this.gesture;
203
291
  const touch = event.touches[0];
204
292
  if (!g || !touch) return;
293
+ if (this.held) this.carryOn();
205
294
  const pos = g.horizontal ? touch.clientX : touch.clientY;
206
295
  if (g.ours === null) {
207
296
  const dx = touch.clientX - g.startX;
@@ -218,10 +307,12 @@ export class TouchPaging {
218
307
  g.from = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
219
308
  g.fromPos = pos;
220
309
  }
310
+ if (event.cancelable) event.preventDefault();
221
311
  const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
222
312
  const offset = Math.min(max, Math.max(0, g.from - (pos - g.fromPos)));
223
313
  if (g.horizontal) g.el.scrollLeft = offset;
224
314
  else g.el.scrollTop = offset;
315
+ g.page = pageAt(g.el, g.horizontal, offset);
225
316
  g.samples.push({ t: event.timeStamp, pos });
226
317
  if (g.samples.length > 20) g.samples.shift();
227
318
  };
@@ -230,6 +321,7 @@ export class TouchPaging {
230
321
  const g = this.gesture;
231
322
  if (!g) return;
232
323
  if (event.touches.length > 0) return;
324
+ if (this.held) this.carryOn();
233
325
  this.gesture = null;
234
326
  if (!g.ours) return;
235
327
  const el = g.el;
@@ -253,15 +345,10 @@ export class TouchPaging {
253
345
  }
254
346
  const duration = runDuration(distance);
255
347
  const started = performance.now();
348
+ // The run writes the offset every frame and the last write is the page itself, whatever
349
+ // else moved the view meanwhile (the offset Safari reads back can trail a write); only an
350
+ // app's own scroll command ends it early (`interrupt`).
256
351
  const step = (now: number): void => {
257
- const run = this.run;
258
- const current = horizontal ? el.scrollLeft : el.scrollTop;
259
- // Another writer moved the view (an app re-centring on the page): the run is over.
260
- if (run && Math.abs(current - run.written) > 1) {
261
- this.stopRun();
262
- this.onRest(el);
263
- return;
264
- }
265
352
  const t = (now - started) / duration;
266
353
  const offset = t >= 1 ? to : from + distance * easeOut(t);
267
354
  if (horizontal) el.scrollLeft = offset;
@@ -272,9 +359,9 @@ export class TouchPaging {
272
359
  this.onRest(el);
273
360
  return;
274
361
  }
275
- this.run = { el, frame: requestAnimationFrame(step), written: offset };
362
+ this.run = { el, frame: requestAnimationFrame(step) };
276
363
  };
277
- this.run = { el, frame: requestAnimationFrame(step), written: from };
364
+ this.run = { el, frame: requestAnimationFrame(step) };
278
365
  }
279
366
 
280
367
  private stopRun(): void {