@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.
- package/package.json +1 -1
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/blockedHosts.ts +3 -1
- package/src/corsProxy.ts +3 -1
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +47 -5
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +4 -0
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +304 -13
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
package/src/hostRequests.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/src/interaction.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
758
|
-
|
|
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,
|