@webview-native-ui/engine 0.0.0-stage → 0.0.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.
Files changed (47) hide show
  1. package/LICENSE +18 -0
  2. package/NOTICE +39 -0
  3. package/README.md +623 -2
  4. package/WebViewNativeUIEngine.podspec +17 -0
  5. package/dist/bridge.d.ts +4 -0
  6. package/dist/bridge.js +15 -0
  7. package/dist/geometry.d.ts +2 -0
  8. package/dist/geometry.js +25 -0
  9. package/dist/index.d.ts +10 -0
  10. package/dist/index.js +9 -0
  11. package/dist/ionic.d.ts +16 -0
  12. package/dist/ionic.js +31 -0
  13. package/dist/native-bootstrap.d.ts +8 -0
  14. package/dist/native-bootstrap.js +138 -0
  15. package/dist/native-session.d.ts +48 -0
  16. package/dist/native-session.js +519 -0
  17. package/dist/overlays.d.ts +42 -0
  18. package/dist/overlays.js +181 -0
  19. package/dist/projection-events.d.ts +12 -0
  20. package/dist/projection-events.js +44 -0
  21. package/dist/projection-placement.d.ts +14 -0
  22. package/dist/projection-placement.js +18 -0
  23. package/dist/runtime.d.ts +2 -0
  24. package/dist/runtime.js +11 -0
  25. package/dist/scroll-bindings.d.ts +11 -0
  26. package/dist/scroll-bindings.js +55 -0
  27. package/dist/scroll-placement.d.ts +5 -0
  28. package/dist/scroll-placement.js +85 -0
  29. package/dist/snapshot-styles.d.ts +2 -0
  30. package/dist/snapshot-styles.js +14 -0
  31. package/dist/types.d.ts +88 -0
  32. package/dist/types.js +1 -0
  33. package/dist/visibility.d.ts +6 -0
  34. package/dist/visibility.js +29 -0
  35. package/dist/web-projection-scope.d.ts +6 -0
  36. package/dist/web-projection-scope.js +8 -0
  37. package/dist/web-projection.d.ts +50 -0
  38. package/dist/web-projection.js +835 -0
  39. package/docs/cordova.md +125 -0
  40. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +16 -0
  41. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +256 -0
  42. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +15 -0
  43. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +311 -0
  44. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +14 -0
  45. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +27 -0
  46. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +117 -0
  47. package/package.json +64 -4
@@ -0,0 +1,519 @@
1
+ import { projectionPlacement } from './projection-placement.js';
2
+ import { getOverlayCoordinator } from './overlays.js';
3
+ import { readScrollPlacement } from './scroll-placement.js';
4
+ import { projectionAncestors } from './geometry.js';
5
+ import { createWebProjection } from './web-projection.js';
6
+ import { createWebProjectionScope } from './web-projection-scope.js';
7
+ import { bindProjectionEvents } from './projection-events.js';
8
+ /** Native startup calls this; application code does not initialize or wrap the plugin. */
9
+ export const startNativeSession = async (config, nativeBridge, plugin = {
10
+ addListener: async (name, listener) => {
11
+ const capacitor = globalThis.Capacitor;
12
+ return capacitor.addListener(config.plugin, name, listener);
13
+ },
14
+ }) => {
15
+ const sourceID = 'view';
16
+ const listeners = config.receiveListeners;
17
+ let stopped = false;
18
+ let ready = false;
19
+ let requestedDestination = 'native';
20
+ let destination = 'native';
21
+ const overlays = getOverlayCoordinator(document);
22
+ const effectiveDestination = () => requestedDestination === 'native' && target && overlays.isCovered(target) ? 'web' : requestedDestination;
23
+ let automaticMotion = false;
24
+ let placementPending = false;
25
+ let nativeScroll = false;
26
+ let scrollOwner;
27
+ let scrollTimer;
28
+ let manager;
29
+ let startingProjection = false;
30
+ const beginProjection = () => {
31
+ const previous = startingProjection;
32
+ startingProjection = true;
33
+ try {
34
+ manager?.begin();
35
+ }
36
+ finally {
37
+ startingProjection = previous;
38
+ }
39
+ };
40
+ let target;
41
+ const placementOwner = {};
42
+ let coveredToken;
43
+ let placementEpoch = 0;
44
+ const clearPlacement = () => {
45
+ placementEpoch++;
46
+ coveredToken = undefined;
47
+ const binding = projectionPlacement(target);
48
+ if (binding?.owner === placementOwner) {
49
+ binding.projected = false;
50
+ binding.owner = undefined;
51
+ }
52
+ };
53
+ const bridge = {
54
+ listen: (listener) => nativeBridge.listen(listener),
55
+ send: (request) => {
56
+ if (!config.placementHandoff)
57
+ return nativeBridge.send(request);
58
+ const binding = projectionPlacement(target);
59
+ if (request.action === 'cover') {
60
+ const epoch = ++placementEpoch;
61
+ coveredToken = request.token;
62
+ if (binding) {
63
+ binding.owner = placementOwner;
64
+ binding.projected = false;
65
+ }
66
+ return nativeBridge
67
+ .send(request)
68
+ .then((result) => {
69
+ if (epoch === placementEpoch && binding === projectionPlacement(target) && binding?.owner === placementOwner)
70
+ binding.projected = result?.shown === true;
71
+ return result;
72
+ })
73
+ .catch((error) => {
74
+ if (epoch === placementEpoch && binding?.owner === placementOwner) {
75
+ clearPlacement();
76
+ binding.invalidate();
77
+ }
78
+ throw error;
79
+ });
80
+ }
81
+ const releasing = request.action === 'release' && request.token === coveredToken;
82
+ const revealing = coveredToken &&
83
+ (request.action === 'restore' || releasing || request.action === 'reset' || (request.action === 'visibility' && request.visible));
84
+ if (revealing)
85
+ placementEpoch++;
86
+ const epoch = placementEpoch;
87
+ const failed = () => {
88
+ if (epoch !== placementEpoch || binding?.owner !== placementOwner)
89
+ return;
90
+ clearPlacement();
91
+ binding.invalidate();
92
+ };
93
+ // Measure synchronously and use the existing ordered reveal/draw acknowledgement.
94
+ try {
95
+ const payload = revealing && binding?.owner === placementOwner
96
+ ? { ...request, token: ('token' in request ? request.token : coveredToken), placement: binding.measure() }
97
+ : request;
98
+ return nativeBridge
99
+ .send(payload)
100
+ .then((result) => {
101
+ if (epoch === placementEpoch &&
102
+ (request.action === 'resume' || releasing || request.action === 'reset' || request.action === 'visibility'))
103
+ clearPlacement();
104
+ return result;
105
+ })
106
+ .catch((error) => {
107
+ failed();
108
+ throw error;
109
+ });
110
+ }
111
+ catch (error) {
112
+ failed();
113
+ return Promise.reject(error);
114
+ }
115
+ },
116
+ };
117
+ let revision = 0;
118
+ let contentRevision = 0;
119
+ let generation = 0;
120
+ let restoring = false;
121
+ let initializing = false;
122
+ let scheduled = false;
123
+ let reconciling = false;
124
+ const style = document.createElement('style');
125
+ const marker = `data-native-ui-session-${config.id.replace(/[^a-zA-Z0-9-]/g, '')}`;
126
+ style.textContent = `[${marker}="native"], [${marker}="native"] *, [${marker}="none"], [${marker}="none"] * {visibility:hidden!important}`;
127
+ document.documentElement.append(style);
128
+ const findTarget = () => {
129
+ const matches = document.querySelectorAll(config.projectionSelector);
130
+ return matches.length === 1 && matches[0] instanceof HTMLElement ? matches[0] : undefined;
131
+ };
132
+ const setVisibility = () => {
133
+ if (!target)
134
+ return;
135
+ if (destination === 'web' || (destination === 'native' && placementPending))
136
+ target.removeAttribute(marker);
137
+ else
138
+ target.setAttribute(marker, destination);
139
+ };
140
+ const sources = () => {
141
+ if (!target?.isConnected)
142
+ return [];
143
+ const r = target.getBoundingClientRect();
144
+ const frame = { x: r.x, y: r.y, width: r.width, height: r.height };
145
+ const scroll = scrollOwner && readScrollPlacement(target, scrollOwner, () => config.id);
146
+ return [
147
+ {
148
+ element: target,
149
+ scrollElement: scrollOwner,
150
+ control: { id: sourceID, ...frame, ...(scroll ? { scroll } : {}) },
151
+ },
152
+ ];
153
+ };
154
+ const recoverFailedProjection = async () => {
155
+ if (stopped)
156
+ return;
157
+ const version = generation;
158
+ const native = config.failedFallback === 'native' && effectiveDestination() === 'native' && !!target?.isConnected;
159
+ await bridge.send({ action: 'visibility', visible: native }).catch(() => { });
160
+ if (stopped || version !== generation)
161
+ return;
162
+ if (native || destination === 'none')
163
+ setVisibility();
164
+ else
165
+ target?.removeAttribute(marker);
166
+ };
167
+ const applyFailedFallback = () => {
168
+ if (!automaticMotion || destination !== 'native' || !target?.isConnected || manager?.token || manager?.restoring)
169
+ return;
170
+ const native = config.failedFallback === 'native';
171
+ void bridge.send({ action: 'visibility', visible: native }).catch(() => { });
172
+ if (native)
173
+ setVisibility();
174
+ else
175
+ target.removeAttribute(marker);
176
+ };
177
+ const wake = () => {
178
+ // begin() can synchronously retire a previous return before installing its next
179
+ // image. That intermediate state is not a failed projection or a new reveal.
180
+ if (stopped || startingProjection)
181
+ return;
182
+ applyFailedFallback();
183
+ if (destination === 'web' && manager && !manager.token && !manager.restoring) {
184
+ void bridge.send({ action: 'visibility', visible: false }).catch(() => { });
185
+ target?.removeAttribute(marker);
186
+ return;
187
+ }
188
+ if (restoring || !manager?.restoring || destination !== 'native')
189
+ return;
190
+ restoring = true;
191
+ const version = generation;
192
+ const current = manager;
193
+ // The native library acknowledges after the live views have entered a draw pass.
194
+ void bridge
195
+ .send({ action: 'resume' })
196
+ .then(() => {
197
+ if (stopped || version !== generation || current !== manager)
198
+ return;
199
+ placementPending = false;
200
+ current.updated(undefined, [sourceID], ++revision);
201
+ setVisibility();
202
+ schedule();
203
+ })
204
+ .catch(() => {
205
+ if (version !== generation || stopped)
206
+ return;
207
+ current.invalidate();
208
+ void recoverFailedProjection();
209
+ })
210
+ .finally(() => {
211
+ restoring = false;
212
+ wake();
213
+ });
214
+ };
215
+ const reconcile = async () => {
216
+ if (stopped || initializing || automaticMotion)
217
+ return;
218
+ if (manager?.restoring) {
219
+ wake();
220
+ return;
221
+ }
222
+ const found = findTarget();
223
+ if (found !== target) {
224
+ generation++;
225
+ clearPlacement();
226
+ target?.removeAttribute(marker);
227
+ await manager?.destroy();
228
+ manager = undefined;
229
+ if (stopped)
230
+ return;
231
+ target = found;
232
+ placementPending = false;
233
+ sizeObserver.disconnect();
234
+ if (target)
235
+ sizeObserver.observe(target);
236
+ }
237
+ refreshScrollListeners();
238
+ destination = effectiveDestination();
239
+ if (!target) {
240
+ await bridge.send({ action: 'visibility', visible: false });
241
+ return;
242
+ }
243
+ if (!manager) {
244
+ initializing = true;
245
+ const version = generation;
246
+ try {
247
+ const created = await createWebProjection(createWebProjectionScope(target, config.id), bridge, {
248
+ wake,
249
+ blocked: () => destination !== 'native' || automaticMotion || (placementPending && !manager?.token),
250
+ revision: () => revision,
251
+ captureRevision: config.captureMode === 'view' ? () => contentRevision : undefined,
252
+ transient: true,
253
+ opaqueUnderlay: config.captureMode === 'view',
254
+ });
255
+ if (stopped || version !== generation) {
256
+ await created.destroy();
257
+ return;
258
+ }
259
+ manager = created;
260
+ }
261
+ finally {
262
+ initializing = false;
263
+ }
264
+ }
265
+ if (stopped)
266
+ return;
267
+ if (destination === 'native' && placementPending) {
268
+ await bridge.send({ action: 'visibility', visible: false });
269
+ setVisibility();
270
+ return;
271
+ }
272
+ if (destination === 'native') {
273
+ const version = generation;
274
+ await bridge.send({ action: 'visibility', visible: true });
275
+ if (stopped || version !== generation || destination !== 'native')
276
+ return;
277
+ setVisibility();
278
+ scrollOwner = [...projectionAncestors(target)].find((element) => {
279
+ const style = getComputedStyle(element);
280
+ return (/auto|scroll/.test(style.overflowX + style.overflowY) &&
281
+ (element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth));
282
+ });
283
+ const scroll = scrollOwner && readScrollPlacement(target, scrollOwner, () => config.id);
284
+ const tracking = await bridge.send({ action: 'track', viewportWidth: innerWidth, ...(scroll ? { scroll } : {}) });
285
+ if (stopped || version !== generation)
286
+ return;
287
+ nativeScroll = tracking?.tracked === true;
288
+ manager.consider(config.captureMode === 'view' ? contentRevision : ++revision, sources());
289
+ }
290
+ else {
291
+ await bridge.send({ action: 'visibility', visible: false });
292
+ setVisibility();
293
+ }
294
+ };
295
+ const schedule = () => {
296
+ if (stopped)
297
+ return;
298
+ scheduled = true;
299
+ if (!ready || reconciling)
300
+ return;
301
+ reconciling = true;
302
+ void Promise.resolve().then(async () => {
303
+ try {
304
+ while (scheduled && !stopped) {
305
+ scheduled = false;
306
+ await reconcile();
307
+ }
308
+ }
309
+ catch {
310
+ // Preserve the selected recovery policy without overriding a newer destination.
311
+ await recoverFailedProjection();
312
+ }
313
+ finally {
314
+ reconciling = false;
315
+ if (scheduled)
316
+ schedule();
317
+ }
318
+ });
319
+ };
320
+ const resized = () => {
321
+ manager?.invalidate();
322
+ schedule();
323
+ };
324
+ const sizeObserver = new ResizeObserver(resized);
325
+ window.addEventListener('resize', resized);
326
+ const scrollAncestors = new Set();
327
+ const handledScrolls = new WeakSet();
328
+ const onScroll = (event) => {
329
+ const owner = event.composedPath()[0] ?? event.target;
330
+ if (stopped ||
331
+ destination !== 'native' ||
332
+ !target ||
333
+ handledScrolls.has(event) ||
334
+ (owner !== document && ![...projectionAncestors(target)].includes(owner)))
335
+ return;
336
+ handledScrolls.add(event);
337
+ if (nativeScroll && owner === scrollOwner) {
338
+ manager?.changed(owner);
339
+ return;
340
+ }
341
+ if (!automaticMotion) {
342
+ // A fresh gesture supersedes the preceding presentation's pending draw ACK.
343
+ // begin() retires that return image and starts another presentation on the same manager.
344
+ if (restoring || manager?.restoring)
345
+ generation++;
346
+ automaticMotion = true;
347
+ beginProjection();
348
+ placementPending = config.failedFallback !== 'native';
349
+ if (manager?.token)
350
+ target.removeAttribute(marker);
351
+ else
352
+ applyFailedFallback();
353
+ }
354
+ else if (config.captureMode === 'view')
355
+ manager?.moved();
356
+ else
357
+ manager?.changed();
358
+ clearTimeout(scrollTimer);
359
+ scrollTimer = setTimeout(() => {
360
+ automaticMotion = false;
361
+ if (stopped || destination !== 'native')
362
+ return;
363
+ manager?.end();
364
+ if (!manager?.restoring)
365
+ schedule();
366
+ }, 100);
367
+ };
368
+ // Listen on composed-tree ancestors themselves: non-composed scroll events inside
369
+ // Ionic's shadow tree never reach document, and Node.contains excludes slotted children.
370
+ const refreshScrollListeners = () => {
371
+ const next = new Set(target ? projectionAncestors(target) : []);
372
+ const changed = next.size !== scrollAncestors.size || [...next].some((element) => !scrollAncestors.has(element));
373
+ for (const element of scrollAncestors)
374
+ if (!next.has(element)) {
375
+ element.removeEventListener('scroll', onScroll);
376
+ element.removeEventListener('slotchange', slotChanged);
377
+ scrollAncestors.delete(element);
378
+ }
379
+ for (const element of next)
380
+ if (!scrollAncestors.has(element)) {
381
+ element.addEventListener('scroll', onScroll, { passive: true });
382
+ element.addEventListener('slotchange', slotChanged);
383
+ scrollAncestors.add(element);
384
+ }
385
+ if (changed) {
386
+ nativeScroll = false;
387
+ scrollOwner = undefined;
388
+ manager?.invalidate();
389
+ schedule();
390
+ }
391
+ };
392
+ const slotChanged = () => {
393
+ refreshScrollListeners();
394
+ schedule();
395
+ };
396
+ document.addEventListener('scroll', onScroll, true);
397
+ const observer = new MutationObserver((records) => {
398
+ refreshScrollListeners();
399
+ if (findTarget() !== target)
400
+ schedule();
401
+ // Moving the same host into or out of an overlay changes coverage, not registration.
402
+ else if (target && records.some((record) => record.type === 'childList'))
403
+ applyDestination(effectiveDestination());
404
+ });
405
+ observer.observe(document.documentElement, { childList: true, attributes: true, subtree: true });
406
+ const applyDestination = (next, force = false) => {
407
+ if (stopped || (!force && next === destination))
408
+ return;
409
+ automaticMotion = false;
410
+ clearTimeout(scrollTimer);
411
+ destination = next;
412
+ generation++;
413
+ const version = generation;
414
+ if (destination === 'none') {
415
+ // Retire native visibility before releasing images; release must not resurrect hidden views.
416
+ void bridge
417
+ .send({ action: 'visibility', visible: false })
418
+ .then(() => {
419
+ if (destination !== 'none' || version !== generation || stopped)
420
+ return;
421
+ manager?.invalidate();
422
+ setVisibility();
423
+ })
424
+ .catch(() => {
425
+ if (destination !== 'none' || version !== generation || stopped)
426
+ return;
427
+ manager?.invalidate();
428
+ setVisibility();
429
+ });
430
+ }
431
+ else if (destination === 'web') {
432
+ beginProjection();
433
+ if (!manager?.token)
434
+ void bridge.send({ action: 'visibility', visible: false }).catch(() => { });
435
+ setVisibility();
436
+ }
437
+ else if (destination === 'native') {
438
+ manager?.end();
439
+ if (!manager?.restoring)
440
+ schedule();
441
+ }
442
+ };
443
+ // Let a renderer claim a native child window during the same Ionic event dispatch.
444
+ const stopOverlays = overlays.subscribe(() => queueMicrotask(() => applyDestination(effectiveDestination())));
445
+ let events;
446
+ const destroy = async () => {
447
+ if (stopped)
448
+ return;
449
+ stopped = true;
450
+ clearPlacement();
451
+ generation++;
452
+ stopOverlays();
453
+ observer.disconnect();
454
+ sizeObserver.disconnect();
455
+ window.removeEventListener('resize', resized);
456
+ document.removeEventListener('scroll', onScroll, true);
457
+ for (const element of scrollAncestors) {
458
+ element.removeEventListener('scroll', onScroll);
459
+ element.removeEventListener('slotchange', slotChanged);
460
+ }
461
+ scrollAncestors.clear();
462
+ clearTimeout(scrollTimer);
463
+ target?.removeAttribute(marker);
464
+ style.remove();
465
+ await Promise.allSettled([Promise.resolve(events?.remove()), manager?.destroy()]);
466
+ };
467
+ try {
468
+ const initialGeneration = generation;
469
+ events = await bindProjectionEvents({
470
+ addListener: (name, listener) => plugin.addListener(name, listener),
471
+ update: listeners?.update,
472
+ projection: listeners?.projection,
473
+ accepts: (event) => !listeners?.match ||
474
+ Object.entries(listeners?.match).every(([key, value]) => typeof event === 'object' && event !== null && event[key] === value),
475
+ }, (next, updated) => {
476
+ if (stopped)
477
+ return;
478
+ if (next) {
479
+ if (next === 'native')
480
+ placementPending = false;
481
+ requestedDestination = next;
482
+ void bridge.send({ action: 'destination', destination: next }).catch(() => { });
483
+ applyDestination(effectiveDestination(), true);
484
+ }
485
+ if (updated) {
486
+ contentRevision++;
487
+ manager?.changed();
488
+ if (destination === 'native')
489
+ schedule();
490
+ }
491
+ });
492
+ const initial = await bridge.send({ action: 'connect' });
493
+ if (generation === initialGeneration && ['native', 'web', 'none'].includes(initial?.destination))
494
+ requestedDestination = initial.destination;
495
+ destination = effectiveDestination();
496
+ ready = true;
497
+ schedule();
498
+ return {
499
+ destroy,
500
+ show: () => {
501
+ placementPending = false;
502
+ requestedDestination = 'native';
503
+ applyDestination(effectiveDestination(), true);
504
+ },
505
+ hide: () => {
506
+ requestedDestination = destination = 'none';
507
+ generation++;
508
+ automaticMotion = false;
509
+ clearTimeout(scrollTimer);
510
+ manager?.invalidate();
511
+ setVisibility();
512
+ },
513
+ };
514
+ }
515
+ catch (error) {
516
+ await destroy();
517
+ throw error;
518
+ }
519
+ };
@@ -0,0 +1,42 @@
1
+ /** Shared by npm consumers and the runtime injected from the native library. */
2
+ export type OverlayPhase = 'willPresent' | 'didPresent' | 'willDismiss' | 'didDismiss' | 'change';
3
+ export interface OverlayChange {
4
+ element?: HTMLElement;
5
+ phase: OverlayPhase;
6
+ event?: Event;
7
+ }
8
+ export interface OverlayDefinition {
9
+ selector: string;
10
+ events: Partial<Record<OverlayPhase, string>>;
11
+ }
12
+ export interface OverlayOptions {
13
+ /** Additional overlay components; Ionic defaults remain enabled. */
14
+ additional?: readonly OverlayDefinition[];
15
+ exclude?: string;
16
+ /** Return undefined to use the normal DOM/backdrop relationship. */
17
+ covers?: (overlay: HTMLElement, target: HTMLElement) => boolean | undefined;
18
+ }
19
+ export interface OverlayElement extends HTMLElement {
20
+ presented?: boolean;
21
+ overlayIndex?: number;
22
+ showBackdrop?: boolean;
23
+ initialBreakpoint?: number;
24
+ backdropBreakpoint?: number;
25
+ }
26
+ /** Whether a Web overlay covers the underlying surface, independently of its renderer. */
27
+ export declare const usesOverlayBackdrop: (element: HTMLElement, breakpoint?: number) => boolean;
28
+ declare const createCoordinator: (doc: Document) => {
29
+ readonly active: ReadonlySet<HTMLElement>;
30
+ subscribe(listener: (change: OverlayChange) => void): () => void;
31
+ /** Renderer ownership is explicit because a child native window is outside the DOM stack. */
32
+ setNative(element: HTMLElement, value: boolean): void;
33
+ isExcluded(element: HTMLElement): boolean;
34
+ isNative(element: HTMLElement): boolean;
35
+ readonly stack: HTMLElement[];
36
+ isCovered(target: HTMLElement): boolean;
37
+ enable(defaults: readonly OverlayDefinition[], config?: OverlayOptions): void;
38
+ };
39
+ export type OverlayCoordinator = ReturnType<typeof createCoordinator>;
40
+ /** Access is inert: only the explicit framework initializer installs DOM listeners. */
41
+ export declare const getOverlayCoordinator: (doc: Document) => OverlayCoordinator;
42
+ export {};