@swiftbrowser/web 0.17.8 → 0.18.1

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.
@@ -37,14 +37,18 @@ import type {
37
37
  ClipboardRequestParams,
38
38
  TranslateRequestParams,
39
39
  PickColorRequestParams,
40
+ UndoRequestParams,
40
41
  SharedFile,
41
42
  } from './protocol';
43
+ import { browserAudioRecorder, type AudioRecorderHost } from './audioRecorder';
44
+ import { browserFiles, type FilesHost } from './files';
42
45
  import { browserClipboardHost, performClipboard, type ClipboardHost } from './clipboard';
43
46
  import { browserMediaHost, performMedia, type MediaHost } from './media';
44
47
  import { browserMediaSessionHost, MediaSessionBridge, performMediaSession } from './mediaSession';
45
48
  import { browserScannerHost, performScanner, type ScannerHost, type ScannerResult } from './scanner';
46
49
  import { browserTranslationHost, performTranslate, type TranslationHost } from './translation';
47
50
  import { chooseColor, performPickColor, type ColorChooser } from './colorPicker';
51
+ import { browserUndoHost, performUndo, type UndoHost } from './undo';
48
52
  import { WebSocketHost, type WebSocketLike } from './webSocket';
49
53
  import { browserWebAuthHost, WebAuthSessions, type WebAuthResult } from './webAuth';
50
54
 
@@ -91,8 +95,16 @@ export interface PickFilesResult {
91
95
  }
92
96
 
93
97
  /** The browser facilities a request handler uses; tests substitute them. */
98
+ /**
99
+ * What the environment fetches with: the global `fetch`'s call, and no more.
100
+ * Not `typeof fetch`, which in Bun's types also carries `fetch.preconnect`,
101
+ * so a wrapper (the CORS proxy's, the blocked hosts', an app's own) would
102
+ * not be one.
103
+ */
104
+ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
105
+
94
106
  export interface HostRequestEnvironment {
95
- fetch: typeof fetch;
107
+ fetch: FetchLike;
96
108
  /** Decodes an image and resolves with its natural size; rejects when it cannot be loaded. */
97
109
  loadImage: (url: string) => Promise<ImageResult>;
98
110
  /** The Web Share API (`navigator.share`), where the browser has it. */
@@ -129,10 +141,16 @@ export interface HostRequestEnvironment {
129
141
  mediaSession?: MediaSessionBridge;
130
142
  /** The system clipboard (`clipboard`, clipboard.ts). */
131
143
  clipboard?: ClipboardHost;
144
+ /** The microphone, recording as `AVAudioRecorder` does (audioRecorder.ts); `useAudioRecorder` calls it. */
145
+ audioRecorder?: AudioRecorderHost;
146
+ /** The app's files (files.ts: its IndexedDB home on a page, its sandbox natively); `FileManager` in @swiftbrowser/react calls it. */
147
+ files?: FilesHost;
132
148
  /** The browser's built-in translator (`translate`, translation.ts), where it has one. */
133
149
  translation?: TranslationHost;
134
150
  /** The browser's color chooser (`pickColor`, colorPicker.ts); it must open before it returns. */
135
151
  pickColor?: ColorChooser;
152
+ /** The system's undo for the app's undo manager (undo.ts): the keyboard here, the window's `UndoManager` natively; `useUndoManager` listens. */
153
+ undoManager?: UndoHost;
136
154
  }
137
155
 
138
156
  /**
@@ -190,6 +208,8 @@ export function installPushService(workerURL: string, scope: string): void {
190
208
  if (push) notificationHost.push = push;
191
209
  }
192
210
  const scannerHost = browserScannerHost();
211
+ const audioRecorderHost = browserAudioRecorder(() => browserEnvironment.files);
212
+ const filesHost = browserFiles();
193
213
  const webAuthHost = browserWebAuthHost();
194
214
  const mediaHost = browserMediaHost(typeof document !== 'undefined' ? downloadBlob : undefined);
195
215
  const mediaSessionHost = browserMediaSessionHost();
@@ -198,13 +218,16 @@ const translationHost = browserTranslationHost();
198
218
 
199
219
  export const browserEnvironment: HostRequestEnvironment = {
200
220
  fetch: (input, init) => fetch(input, init),
201
- loadImage: (url) =>
202
- new Promise((resolve, reject) => {
221
+ loadImage: async (url) => {
222
+ // A `file:` URL of the app's files loads at its object URL (`loadableURL`).
223
+ const src = await loadableURL(url);
224
+ return new Promise((resolve, reject) => {
203
225
  const img = new Image();
204
226
  img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
205
227
  img.onerror = () => reject(new Error(`network: could not load ${url}`));
206
- img.src = url;
207
- }),
228
+ img.src = src;
229
+ });
230
+ },
208
231
  ...(typeof navigator !== 'undefined' && typeof navigator.share === 'function'
209
232
  ? { share: (data: ShareData) => navigator.share(data) }
210
233
  : {}),
@@ -227,10 +250,26 @@ export const browserEnvironment: HostRequestEnvironment = {
227
250
  ...(mediaHost ? { media: mediaHost } : {}),
228
251
  ...(mediaSessionHost ? { mediaSession: new MediaSessionBridge(mediaSessionHost) } : {}),
229
252
  ...(clipboardHost ? { clipboard: clipboardHost } : {}),
253
+ ...(audioRecorderHost ? { audioRecorder: audioRecorderHost } : {}),
254
+ ...(filesHost ? { files: filesHost } : {}),
230
255
  ...(translationHost ? { translation: translationHost } : {}),
231
256
  ...(typeof document !== 'undefined' ? { pickColor: chooseColor } : {}),
257
+ ...(typeof document !== 'undefined' ? { undoManager: browserUndoHost(document) } : {}),
232
258
  };
233
259
 
260
+ /**
261
+ * The URL a media element or an image loads `url` at: a `file:` URL of the
262
+ * app's files becomes an object URL of its bytes through the files host (a
263
+ * promise), any other URL is itself. A file that cannot be loaded resolves to
264
+ * the `file:` URL as given, so the element fails to load it and says so.
265
+ */
266
+ export function loadableURL(url: string): string | Promise<string> {
267
+ if (!url.startsWith('file:')) return url;
268
+ const files = browserEnvironment.files;
269
+ if (!files) return url;
270
+ return files.loadURL(url).catch(() => url);
271
+ }
272
+
234
273
  /**
235
274
  * Performs one request. Resolves with the result the app expects for the
236
275
  * op's kind; rejects with an `Error` whose message starts with a short tag
@@ -287,6 +326,9 @@ export async function performHostRequest(
287
326
  case 'pickColor':
288
327
  // Not awaited before the chooser opens: it is clicked inside the tap that sent the request.
289
328
  return performPickColor(op.params as PickColorRequestParams, env.pickColor);
329
+ case 'undo':
330
+ // The Swift app's undo manager, after a change: told to the host, which shows nothing on a page (undo.ts).
331
+ return performUndo(op.params as UndoRequestParams, env.undoManager);
290
332
  default:
291
333
  throw new Error(`unsupported: request kind ${String((op as { kind: unknown }).kind)}`);
292
334
  }
@@ -35,6 +35,12 @@
35
35
  * directly for pull to refresh (the browser cancels the pointer once it
36
36
  * scrolls the list).
37
37
  *
38
+ * A gesture's `mode` changes its place in the order (src/gestureComposition.ts):
39
+ * `highPriority` is asked before the wrappers inside it, and `simultaneous`
40
+ * never claims: it begins when its own distance is met and receives every
41
+ * move beside the owner, whoever that turns out to be, so a canvas can track
42
+ * a finger its items are also dragging.
43
+ *
38
44
  * Two more candidates lift something (src/dragDrop.ts does the DOM work):
39
45
  * a movable `listrow` (by its edit-mode grip from the pointer down, or by a
40
46
  * long press when the list is not editing) is reordered among its
@@ -44,9 +50,10 @@
44
50
  */
45
51
 
46
52
  import { RowReorder, ViewDrag } from './dragDrop';
53
+ import { isSimultaneous, placeWrapper } from './gestureComposition';
47
54
  import type { LayoutNode } from './layout';
48
55
  import { symbolOf, textOf, tintOf, unwrapStyledNode } from './nodeQueries';
49
- import type { DragEvent, ElementId, RendererEvent } from './protocol';
56
+ import type { DragEvent, ElementId, GestureCoordinateSpace, GestureMode, RendererEvent } from './protocol';
50
57
  import { colorToCSS } from './styles';
51
58
  import {
52
59
  SWIPE_ACTION_WIDTH,
@@ -136,17 +143,27 @@ interface SwipeHost {
136
143
  actions: Record<SwipeEdge, ActionSpec[]>;
137
144
  }
138
145
 
146
+ interface GestureCandidate {
147
+ kind: 'gesture';
148
+ el: HTMLElement;
149
+ id: ElementId;
150
+ drag: boolean;
151
+ longPress: boolean;
152
+ minDistance: number;
153
+ minDuration: number;
154
+ /** `.highPriorityGesture` / `.simultaneousGesture`; absent for `.gesture`. */
155
+ mode?: GestureMode;
156
+ /** The space of the drag's `location` and `startLocation`. */
157
+ space: GestureCoordinateSpace;
158
+ /** The element's box when the drag began (locations are relative to it). */
159
+ rect: DOMRect | null;
160
+ /** A simultaneous gesture that has begun (it runs beside the owner). */
161
+ active: boolean;
162
+ }
163
+
139
164
  type Candidate =
140
165
  | { kind: 'swipeback'; screen: HTMLElement; below: HTMLElement | null; id: ElementId; width: number }
141
- | {
142
- kind: 'gesture';
143
- el: HTMLElement;
144
- id: ElementId;
145
- drag: boolean;
146
- longPress: boolean;
147
- minDistance: number;
148
- minDuration: number;
149
- }
166
+ | GestureCandidate
150
167
  | { kind: 'contextmenu'; el: HTMLElement; id: ElementId }
151
168
  | { kind: 'swipe'; host: SwipeHost }
152
169
  | { kind: 'sheet'; sheet: HTMLElement; card: HTMLElement; id: ElementId }
@@ -165,15 +182,18 @@ interface Session {
165
182
  samples: Sample[];
166
183
  candidates: Candidate[];
167
184
  claimed: Candidate | null;
185
+ /** The simultaneous gestures running beside the owner (or alone, before any claim). */
186
+ along: GestureCandidate[];
187
+ /** The root's box, for `global` drag locations. */
188
+ rootRect: DOMRect | null;
168
189
  /** A long press fired: the session ignores further motion. */
169
190
  done: boolean;
170
191
  suppressClick: boolean;
171
192
  longPressTimer: number | null;
172
193
  /** The pointer went down on a pushed screen's edge strip (touch): a plain tap is forwarded. */
173
194
  edgeStrip: HTMLElement | null;
174
- // gesture drag
175
- gestureRect: DOMRect | null;
176
- pendingDrag: DragEvent | null;
195
+ // gesture drag (one per gesture: the owner's and the simultaneous ones')
196
+ pendingDrag: Map<ElementId, DragEvent> | null;
177
197
  raf: number | null;
178
198
  // swipe row
179
199
  swipeStart: number;
@@ -322,11 +342,12 @@ export class InteractionController {
322
342
  samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }],
323
343
  candidates,
324
344
  claimed: null,
345
+ along: [],
346
+ rootRect: null,
325
347
  done: false,
326
348
  suppressClick: false,
327
349
  longPressTimer: null,
328
350
  edgeStrip,
329
- gestureRect: null,
330
351
  pendingDrag: null,
331
352
  raf: null,
332
353
  swipeStart: 0,
@@ -353,8 +374,12 @@ export class InteractionController {
353
374
  session.longPressTimer = window.setTimeout(() => this.fireLongPress(session, presser), duration);
354
375
  }
355
376
  // An immediate drag gesture (`minimumDistance: 0`) and a row's grip own the pointer from the start.
356
- const immediate = candidates.find((c) => (c.kind === 'gesture' && c.drag && c.minDistance <= 0) || (c.kind === 'reorder' && c.immediate));
357
- if (immediate && candidates[0] === immediate) this.claim(session, immediate, event.pointerId);
377
+ const immediate = candidates.find((c) => (c.kind === 'gesture' && c.drag && c.minDistance <= 0 && !isSimultaneous(c)) || (c.kind === 'reorder' && c.immediate));
378
+ if (immediate && candidates.find((c) => !isSimultaneous(c)) === immediate) this.claim(session, immediate, event.pointerId);
379
+ // An immediate simultaneous gesture begins beside whatever else there is.
380
+ for (const c of candidates) {
381
+ if (c.kind === 'gesture' && isSimultaneous(c) && c.drag && c.minDistance <= 0) this.beginAlong(session, c);
382
+ }
358
383
  };
359
384
 
360
385
  /** A lifted row or view moves with the finger: the list under it must not scroll. */
@@ -378,12 +403,16 @@ export class InteractionController {
378
403
  window.clearTimeout(session.longPressTimer);
379
404
  session.longPressTimer = null;
380
405
  }
406
+ // A simultaneous gesture begins once its own distance is met, claiming nothing.
407
+ for (const c of session.candidates) {
408
+ if (c.kind === 'gesture' && isSimultaneous(c) && !c.active && this.wants(c, dx, dy, distance)) this.beginAlong(session, c);
409
+ }
381
410
  if (!session.claimed) {
382
- const owner = session.candidates.find((c) => this.wants(c, dx, dy, distance));
383
- if (!owner) return;
384
- this.claim(session, owner, event.pointerId);
411
+ const owner = session.candidates.find((c) => !isSimultaneous(c) && this.wants(c, dx, dy, distance));
412
+ if (owner) this.claim(session, owner, event.pointerId);
385
413
  }
386
414
  if (session.claimed) this.move(session, session.claimed, dx, dy);
415
+ for (const c of session.along) this.move(session, c, dx, dy);
387
416
  };
388
417
 
389
418
  private readonly onPointerUp = (event: PointerEvent): void => {
@@ -412,10 +441,20 @@ export class InteractionController {
412
441
  const dx = session.x - session.startX;
413
442
  const dy = session.y - session.startY;
414
443
  const velocity = velocityFrom(session.samples);
444
+ for (const c of session.along) {
445
+ if (cancelled) this.cancel(session, c, dx, dy);
446
+ else this.end(session, c, dx, dy, velocity);
447
+ }
415
448
  if (session.claimed) {
416
449
  if (cancelled) this.cancel(session, session.claimed, dx, dy);
417
450
  else this.end(session, session.claimed, dx, dy, velocity);
418
451
  session.suppressClick = true;
452
+ } else if (!cancelled && !session.done && this.tapImmediateGesture(session)) {
453
+ session.suppressClick = true;
454
+ } else if (session.along.length > 0 && Math.hypot(dx, dy) >= LONG_PRESS_SLOP) {
455
+ // A simultaneous gesture alone leaves a tap to the taps under it (as
456
+ // SwiftUI's tap and simultaneous drag both fire); a real drag was no tap.
457
+ session.suppressClick = true;
419
458
  } else if (!cancelled && session.edgeStrip && Math.hypot(dx, dy) < LONG_PRESS_SLOP && !session.done) {
420
459
  // A plain tap on the (touch-only) edge strip goes to whatever is under it.
421
460
  this.forwardTap(session.edgeStrip, session.x, session.y);
@@ -424,6 +463,21 @@ export class InteractionController {
424
463
  if (session.suppressClick) suppressNextClick();
425
464
  }
426
465
 
466
+ /**
467
+ * A tap that nothing claimed still reaches an immediate drag gesture
468
+ * (`minimumDistance: 0`) under it, as `began` then `ended`: a back swipe's
469
+ * edge, first in line there, keeps the gesture from claiming at the
470
+ * pointer down, but on iOS the edge pan only begins with motion.
471
+ */
472
+ private tapImmediateGesture(session: Session): boolean {
473
+ if (session.candidates[0]?.kind !== 'swipeback') return false;
474
+ const gesture = session.candidates.find((c): c is GestureCandidate => c.kind === 'gesture' && c.drag && c.minDistance <= 0 && !isSimultaneous(c));
475
+ if (!gesture) return false;
476
+ this.claim(session, gesture, session.pointerId);
477
+ this.end(session, gesture, session.x - session.startX, session.y - session.startY, { x: 0, y: 0 });
478
+ return true;
479
+ }
480
+
427
481
  private detachWindow(): void {
428
482
  window.removeEventListener('pointermove', this.onPointerMove);
429
483
  window.removeEventListener('pointerup', this.onPointerUp);
@@ -453,6 +507,7 @@ export class InteractionController {
453
507
  }
454
508
  // A horizontal scroll view takes horizontal motion from the rows around it.
455
509
  let inHorizontalScroller = false;
510
+ const wrapperStart = out.length;
456
511
  for (let el: HTMLElement | null = target; el && el !== this.host.root; el = el.parentElement) {
457
512
  const id = Number(el.dataset[ID_ATTR]);
458
513
  if (isHorizontalScroller(el)) inHorizontalScroller = true;
@@ -464,7 +519,21 @@ export class InteractionController {
464
519
  if (!drag && !longPress) continue;
465
520
  const minDistance = typeof props['minimumDistance'] === 'number' ? Math.max(0, props['minimumDistance']) : DRAG_MIN_DISTANCE;
466
521
  const minDuration = typeof props['minimumDuration'] === 'number' ? Math.max(0, props['minimumDuration']) * 1000 : LONG_PRESS_MS;
467
- out.push({ kind: 'gesture', el, id, drag, longPress, minDistance, minDuration });
522
+ const mode = props['mode'];
523
+ const candidate: GestureCandidate = {
524
+ kind: 'gesture',
525
+ el,
526
+ id,
527
+ drag,
528
+ longPress,
529
+ minDistance,
530
+ minDuration,
531
+ space: props['coordinateSpace'] === 'global' ? 'global' : 'local',
532
+ rect: null,
533
+ active: false,
534
+ };
535
+ if (mode === 'highPriority' || mode === 'simultaneous') candidate.mode = mode;
536
+ placeWrapper(out, candidate, wrapperStart);
468
537
  } else if (el.classList.contains('sb-contextmenu') && Number.isInteger(id)) {
469
538
  out.push({ kind: 'contextmenu', el, id });
470
539
  } else if (el.classList.contains('sb-draggable') && Number.isInteger(id)) {
@@ -533,9 +602,19 @@ export class InteractionController {
533
602
  }
534
603
  }
535
604
 
605
+ /** A simultaneous gesture begins: `began` goes out and it hears every move until the pointer lifts. */
606
+ private beginAlong(session: Session, c: GestureCandidate): void {
607
+ c.active = true;
608
+ c.rect = c.el.getBoundingClientRect();
609
+ session.rootRect ??= this.host.root.getBoundingClientRect();
610
+ session.along.push(c);
611
+ this.host.send(this.dragEvent(session, c, 'began', { x: 0, y: 0 }));
612
+ }
613
+
536
614
  private claim(session: Session, c: Candidate, pointerId: number): void {
537
615
  session.claimed = c;
538
- session.candidates = [c];
616
+ // The simultaneous gestures stay: those running keep hearing the pointer, the others may still begin.
617
+ session.candidates = [c, ...session.candidates.filter((other) => other !== c && isSimultaneous(other))];
539
618
  if (session.longPressTimer !== null) {
540
619
  window.clearTimeout(session.longPressTimer);
541
620
  session.longPressTimer = null;
@@ -548,7 +627,8 @@ export class InteractionController {
548
627
  }
549
628
  switch (c.kind) {
550
629
  case 'gesture': {
551
- session.gestureRect = c.el.getBoundingClientRect();
630
+ c.rect = c.el.getBoundingClientRect();
631
+ session.rootRect ??= this.host.root.getBoundingClientRect();
552
632
  this.host.send(this.dragEvent(session, c, 'began', { x: 0, y: 0 }));
553
633
  break;
554
634
  }
@@ -610,14 +690,14 @@ export class InteractionController {
610
690
  private move(session: Session, c: Candidate, dx: number, dy: number): void {
611
691
  switch (c.kind) {
612
692
  case 'gesture': {
613
- // Coalesced: the latest position goes out once per animation frame.
614
- session.pendingDrag = this.dragEvent(session, c, 'changed', { x: 0, y: 0 });
693
+ // Coalesced: the latest position goes out once per animation frame (one per gesture).
694
+ (session.pendingDrag ??= new Map()).set(c.id, this.dragEvent(session, c, 'changed', { x: 0, y: 0 }));
615
695
  if (session.raf === null) {
616
696
  session.raf = requestAnimationFrame(() => {
617
697
  session.raf = null;
618
698
  const pending = session.pendingDrag;
619
699
  session.pendingDrag = null;
620
- if (pending && this.session === session) this.host.send(pending);
700
+ if (pending && this.session === session) for (const event of pending.values()) this.host.send(event);
621
701
  });
622
702
  }
623
703
  return;
@@ -754,8 +834,9 @@ export class InteractionController {
754
834
  return reorder !== null && reorder.settling && reorder.row.parentElement === row.parentElement;
755
835
  }
756
836
 
757
- private dragEvent(session: Session, c: Extract<Candidate, { kind: 'gesture' }>, phase: DragEvent['phase'], velocity: { x: number; y: number }): DragEvent {
758
- const rect = session.gestureRect ?? c.el.getBoundingClientRect();
837
+ /** The event for `c` at the pointer's position, its locations in the gesture's space (the element's box, or the root's). */
838
+ private dragEvent(session: Session, c: GestureCandidate, phase: DragEvent['phase'], velocity: { x: number; y: number }): DragEvent {
839
+ const rect = c.space === 'global' ? (session.rootRect ??= this.host.root.getBoundingClientRect()) : (c.rect ??= c.el.getBoundingClientRect());
759
840
  return {
760
841
  type: 'drag',
761
842
  id: c.id,