@swiftbrowser/web 0.17.7 → 0.18.0

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.
@@ -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,