@webview-native-ui/engine 0.0.0-stage → 0.0.2

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -2
  3. package/WebViewNativeUIEngine.podspec +17 -0
  4. package/dist/dom/geometry.d.ts +2 -0
  5. package/dist/dom/geometry.js +25 -0
  6. package/dist/dom/overlays.d.ts +42 -0
  7. package/dist/dom/overlays.js +181 -0
  8. package/dist/dom/scroll-bindings.d.ts +11 -0
  9. package/dist/dom/scroll-bindings.js +55 -0
  10. package/dist/dom/scroll-placement.d.ts +5 -0
  11. package/dist/dom/scroll-placement.js +85 -0
  12. package/dist/dom/visibility.d.ts +6 -0
  13. package/dist/dom/visibility.js +29 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.js +9 -0
  16. package/dist/ionic.d.ts +16 -0
  17. package/dist/ionic.js +31 -0
  18. package/dist/native/bootstrap.d.ts +8 -0
  19. package/dist/native/bootstrap.js +138 -0
  20. package/dist/native/bridge.d.ts +4 -0
  21. package/dist/native/bridge.js +15 -0
  22. package/dist/native/resource-url.d.ts +2 -0
  23. package/dist/native/resource-url.js +11 -0
  24. package/dist/native/session.d.ts +49 -0
  25. package/dist/native/session.js +520 -0
  26. package/dist/projection/events.d.ts +12 -0
  27. package/dist/projection/events.js +44 -0
  28. package/dist/projection/placement.d.ts +14 -0
  29. package/dist/projection/placement.js +18 -0
  30. package/dist/projection/scope.d.ts +6 -0
  31. package/dist/projection/scope.js +8 -0
  32. package/dist/projection/styles.d.ts +2 -0
  33. package/dist/projection/styles.js +14 -0
  34. package/dist/projection/web.d.ts +52 -0
  35. package/dist/projection/web.js +838 -0
  36. package/dist/types.d.ts +88 -0
  37. package/dist/types.js +1 -0
  38. package/docs/assets/hero.png +0 -0
  39. package/docs/capacitor-plugin.md +279 -0
  40. package/docs/cordova-plugin.md +198 -0
  41. package/docs/cordova.md +3 -0
  42. package/docs/maintenance.md +52 -0
  43. package/docs/projection.md +340 -0
  44. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +16 -0
  45. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +256 -0
  46. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +15 -0
  47. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +314 -0
  48. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +14 -0
  49. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +27 -0
  50. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +117 -0
  51. package/package.json +63 -4
@@ -0,0 +1,838 @@
1
+ import { projectionURL } from '../native/resource-url.js';
2
+ import { bounded, boundedListener } from '../native/bridge.js';
3
+ import { bindProjectionEvents } from './events.js';
4
+ import { projectionAncestors } from '../dom/geometry.js';
5
+ import { snapshotStyles } from './styles.js';
6
+ const layout = (candidates, positionIndependent = false) => JSON.stringify(candidates.map(({ control: c }) => [
7
+ c.id,
8
+ c.kind,
9
+ c.placement,
10
+ positionIndependent ? 0 : c.x,
11
+ positionIndependent ? 0 : c.y,
12
+ c.width,
13
+ c.height,
14
+ c.dark,
15
+ c.rtl,
16
+ ]));
17
+ // Native frames are viewport pixels; absolute children use the target's local
18
+ // padding box. Keep the conversion independent of theme dimensions.
19
+ const coordinateSpace = (target, win, style = win.getComputedStyle(target)) => {
20
+ const rect = target.getBoundingClientRect();
21
+ // offsetWidth/Height round fractional CSS sizes, which would introduce drift.
22
+ const borderBox = (size, sides) => {
23
+ const value = parseFloat(size);
24
+ return style.display !== 'inline' && Number.isFinite(value)
25
+ ? value +
26
+ (style.boxSizing === 'border-box' ? 0 : sides.reduce((sum, side) => sum + (parseFloat(style.getPropertyValue(side)) || 0), 0))
27
+ : NaN;
28
+ };
29
+ const width = borderBox(style.width, ['padding-left', 'padding-right', 'border-left-width', 'border-right-width']);
30
+ const height = borderBox(style.height, ['padding-top', 'padding-bottom', 'border-top-width', 'border-bottom-width']);
31
+ // Inline dimensions may be auto (or an ignored specified width). Native element
32
+ // projection excludes transformed ancestors; use CSS zoom, not rounded offsets.
33
+ let zoom = 1;
34
+ if (!(width > 0 && height > 0)) {
35
+ for (const element of [target, ...projectionAncestors(target)]) {
36
+ const value = (element === target ? style : win.getComputedStyle(element)).getPropertyValue('zoom');
37
+ zoom *= (parseFloat(value) || 1) / (value.endsWith('%') ? 100 : 1);
38
+ }
39
+ }
40
+ const scaleX = width > 0 ? rect.width / width : zoom;
41
+ const scaleY = height > 0 ? rect.height / height : zoom;
42
+ return {
43
+ x: rect.x,
44
+ y: rect.y,
45
+ scaleX: scaleX || 1,
46
+ scaleY: scaleY || 1,
47
+ left: (parseFloat(style.borderLeftWidth) || 0) - target.scrollLeft,
48
+ top: (parseFloat(style.borderTopWidth) || 0) - target.scrollTop,
49
+ position: style.position,
50
+ };
51
+ };
52
+ /** Images are prepared after accepted native updates, never on the overlay's critical path. */
53
+ const createProjectionEngine = async (scope, transport, callbacks) => {
54
+ const captureDelayMs = callbacks.captureDelayMs ?? 50;
55
+ if (!Number.isFinite(captureDelayMs) || captureDelayMs < 0 || captureDelayMs > 2147483647)
56
+ throw new RangeError('captureDelayMs must be a finite, nonnegative timer interval');
57
+ let destination = 'native';
58
+ const doc = scope.element.ownerDocument;
59
+ const win = doc.defaultView;
60
+ // Native tokens are local to a transport; never use them as global CSS identities.
61
+ const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
62
+ const validSource = (source) => source.isConnected && source.ownerDocument === doc;
63
+ const validTarget = (target) => !!target?.isConnected && target.ownerDocument === doc;
64
+ const call = transport.send.bind(transport);
65
+ const imageMarker = 'data-native-ui-snapshot';
66
+ const sourcesChangedEvent = `${scope.namespace}:sources-change`;
67
+ const owners = new Set(), visibleOwners = new Set(), preparedOwners = new Set();
68
+ const scopeIDs = new WeakMap();
69
+ let scopeSerial = 0;
70
+ const signature = (candidates) => JSON.stringify(candidates.map((candidate) => {
71
+ if (!scopeIDs.has(candidate.scope))
72
+ scopeIDs.set(candidate.scope, ++scopeSerial);
73
+ return [scopeIDs.get(candidate.scope), layout([candidate], !!callbacks.opaqueUnderlay)];
74
+ }));
75
+ const sheets = new Map();
76
+ const rootFor = (element) => {
77
+ const root = element.getRootNode();
78
+ return root.nodeType === 11 && 'host' in root ? root : doc;
79
+ };
80
+ const sheetFor = (root) => {
81
+ let pair = sheets.get(root);
82
+ if (!pair) {
83
+ const base = doc.createElement('style'), image = doc.createElement('style');
84
+ base.setAttribute('data-native-ui-snapshot-base', '');
85
+ image.setAttribute(imageMarker, '');
86
+ base.textContent = snapshotStyles;
87
+ (root.nodeType === 11 ? root : root.head).append(base, image);
88
+ pair = { base, image };
89
+ sheets.set(root, pair);
90
+ }
91
+ return pair.image;
92
+ };
93
+ const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
94
+ let handoffPending = false;
95
+ let presented = false;
96
+ let paintFrame = 0;
97
+ let presentation = 0;
98
+ let cached;
99
+ let active;
100
+ let touching = false;
101
+ let ending = false;
102
+ let restoreTimeout = 0;
103
+ let restoreRevision = 0;
104
+ let generation = 0;
105
+ let timer = 0;
106
+ let visualRevision = 0;
107
+ let preparedVisualRevision = -1;
108
+ let stopped = false;
109
+ let flight = false;
110
+ let latest;
111
+ const pendingCleanup = new Set();
112
+ const cleanup = (action, token, afterPaint) => {
113
+ const run = () => {
114
+ if (!pendingCleanup.delete(run))
115
+ return;
116
+ void call({ action, token }).catch(() => { });
117
+ };
118
+ pendingCleanup.add(run);
119
+ if (afterPaint && !stopped)
120
+ win.requestAnimationFrame(() => win.requestAnimationFrame(run));
121
+ else
122
+ run();
123
+ };
124
+ const preparedSources = new Set();
125
+ const clearPrepared = () => {
126
+ preparedSources.forEach((source) => preparedOwners.delete(source));
127
+ preparedSources.clear();
128
+ };
129
+ const dispose = (item, afterPaint = false) => {
130
+ if (!item || item === cached || item === active)
131
+ return;
132
+ item.images.forEach((image) => {
133
+ image.src = '';
134
+ });
135
+ cleanup('release', item.data.token, afterPaint);
136
+ };
137
+ const select = (candidates) => candidates.filter((candidate) => {
138
+ if (!validTarget(candidate.scope.element) || ![candidate.element, ...(candidate.sources ?? [])].every(validSource))
139
+ return false;
140
+ // Native scroll bindings retain offscreen controls. They have no visible
141
+ // pixels to capture and must not prevent the other controls being prepared.
142
+ if (candidate.control.scroll) {
143
+ const rect = candidate.element.getBoundingClientRect();
144
+ const clip = candidate.control.scroll.clip;
145
+ if (rect.right <= clip.x || rect.bottom <= clip.y || rect.left >= clip.x + clip.width || rect.top >= clip.y + clip.height)
146
+ return false;
147
+ }
148
+ return callbacks.eligible?.(candidate) ?? true;
149
+ });
150
+ const prepare = async () => {
151
+ timer = 0;
152
+ if (stopped ||
153
+ destination !== 'native' ||
154
+ flight ||
155
+ touching ||
156
+ active ||
157
+ callbacks.blocked() ||
158
+ !latest ||
159
+ (cached?.revision === latest.revision && preparedVisualRevision === visualRevision))
160
+ return;
161
+ const { revision, candidates } = latest;
162
+ const stamp = generation;
163
+ const visual = visualRevision;
164
+ const size = viewport();
165
+ const selected = select(candidates);
166
+ if (!selected.length)
167
+ return;
168
+ flight = true;
169
+ let result;
170
+ let adopted = false;
171
+ const images = [];
172
+ try {
173
+ let expired = false;
174
+ const capture = call({
175
+ action: 'prepare',
176
+ revision: callbacks.captureRevision?.() ?? revision,
177
+ viewportWidth: win.innerWidth,
178
+ ids: selected.map((item) => item.control.id),
179
+ });
180
+ void capture
181
+ .then((late) => {
182
+ if (expired && late.token)
183
+ void call({ action: 'release', token: late.token }).catch(() => { });
184
+ })
185
+ .catch(() => { });
186
+ try {
187
+ result = await bounded(capture);
188
+ }
189
+ finally {
190
+ expired = true;
191
+ }
192
+ if (!result.token || !result.parts?.length)
193
+ return;
194
+ const ids = new Set(selected.map((candidate) => candidate.control.id));
195
+ if (result.parts.some((part) => (!part.url.startsWith('file:') &&
196
+ !/^\/__native_ui_engine__\/nativeUI[a-fA-F0-9]+\/[a-fA-F0-9-]+-[0-9]+\.png$/.test(part.url)) ||
197
+ !part.ids.length ||
198
+ part.ids.some((id) => !ids.has(id)) ||
199
+ ![part.x, part.y, part.width, part.height].every(Number.isFinite) ||
200
+ part.width <= 0 ||
201
+ part.height <= 0))
202
+ return;
203
+ if (new Set(result.parts.flatMap((part) => part.ids)).size !== ids.size)
204
+ return;
205
+ // One crop may cover a button group, but must have one fixed fallback destination.
206
+ if (result.parts.some((part) => new Set(part.ids.map((id) => selected.find((candidate) => candidate.control.id === id).scope)).size !== 1))
207
+ return;
208
+ await bounded(Promise.all(result.parts.map(async (part) => {
209
+ const image = doc.createElement('img');
210
+ images.push(image);
211
+ image.src = projectionURL(part.url);
212
+ await image.decode();
213
+ return image;
214
+ })));
215
+ if (stopped ||
216
+ destination !== 'native' ||
217
+ touching ||
218
+ active ||
219
+ callbacks.blocked() ||
220
+ generation !== stamp ||
221
+ viewport() !== size ||
222
+ latest?.revision !== revision ||
223
+ selected.some((candidate) => !validTarget(candidate.scope.element) || ![candidate.element, ...(candidate.sources ?? [])].every(validSource)))
224
+ return;
225
+ const previous = cached;
226
+ cached = {
227
+ data: result,
228
+ images,
229
+ sources: Array.from(new Set(selected.flatMap((item) => [item.element, ...(item.sources ?? [])]))),
230
+ targets: result.parts.map((part) => selected.find((candidate) => part.ids.includes(candidate.control.id)).element),
231
+ scopes: result.parts.map((part) => selected.find((candidate) => part.ids.includes(candidate.control.id)).scope),
232
+ parents: result.parts.map((part) => selected.find((candidate) => part.ids.includes(candidate.control.id)).scope.element.parentNode),
233
+ frames: [],
234
+ viewport: size,
235
+ revision,
236
+ layout: signature(selected),
237
+ };
238
+ clearPrepared();
239
+ cached.sources.forEach((source) => {
240
+ preparedOwners.add(source);
241
+ preparedSources.add(source);
242
+ });
243
+ render();
244
+ if (callbacks.opaqueUnderlay)
245
+ void call({ action: 'prepared', token: result.token }).catch(() => { });
246
+ win.dispatchEvent(new Event(sourcesChangedEvent));
247
+ preparedVisualRevision = visual;
248
+ adopted = true;
249
+ dispose(previous);
250
+ }
251
+ catch {
252
+ // Preparing a visual is optional. Preserve the previous image or the Web fallback.
253
+ }
254
+ finally {
255
+ if (!adopted)
256
+ images.forEach((image) => {
257
+ image.src = '';
258
+ });
259
+ if (result?.token && !adopted)
260
+ void call({ action: 'release', token: result.token }).catch(() => { });
261
+ flight = false;
262
+ if (latest && (latest.revision !== revision || visualRevision !== visual))
263
+ schedule();
264
+ }
265
+ };
266
+ // Coalesce changes outside the presentation path. Completed images remain
267
+ // available while their replacement is being rendered and decoded.
268
+ const schedule = () => {
269
+ win.clearTimeout(timer);
270
+ if (!stopped && destination === 'native' && !active && !touching && !callbacks.blocked())
271
+ timer = win.setTimeout(() => void prepare(), captureDelayMs);
272
+ };
273
+ let consideredLayout;
274
+ const consider = (revision, candidates) => {
275
+ if (candidates.some((candidate) => candidate.scope.element.ownerDocument !== doc ||
276
+ [candidate.element, ...(candidate.sources ?? [])].some((element) => element.ownerDocument !== doc)))
277
+ throw new TypeError('All projection sources and fallback scopes must share one Document');
278
+ // Overlay retirement is not a new source layout. Preserve the pre-overlay
279
+ // image until the presentation path has chosen whether to use it.
280
+ if (callbacks.blocked())
281
+ return;
282
+ if (!active && cached && cached.layout !== signature(select(candidates)))
283
+ invalidate();
284
+ const nextLayout = signature(candidates);
285
+ if (consideredLayout !== nextLayout) {
286
+ consideredLayout = nextLayout;
287
+ visualRevision++;
288
+ generation++;
289
+ }
290
+ if (latest?.revision !== revision)
291
+ generation++;
292
+ latest = { revision, candidates };
293
+ if (cached?.revision !== revision || preparedVisualRevision !== visualRevision)
294
+ schedule();
295
+ };
296
+ const changed = (scrollOwner) => {
297
+ // A pre-interaction state image is reusable; an image from another scroll
298
+ // position/clip is not. Invalidate only the surface containing that owner.
299
+ if (!active &&
300
+ cached &&
301
+ scrollOwner &&
302
+ latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner && cached.sources.includes(candidate.element)))
303
+ invalidate();
304
+ if (active) {
305
+ updateOffsets();
306
+ followBorderAnimation();
307
+ }
308
+ visualRevision++;
309
+ generation++;
310
+ schedule();
311
+ };
312
+ const listener = await boundedListener(transport.listen(({ active: pressed }) => {
313
+ touching = pressed;
314
+ generation++;
315
+ win.clearTimeout(timer);
316
+ // The completed cache itself is the pre-interaction image. No separate
317
+ // pin can outlive the interaction and override a newer completed image.
318
+ if (!pressed)
319
+ schedule();
320
+ }));
321
+ const preparedTargets = new Set();
322
+ const clearTarget = (target) => {
323
+ if (preparedTargets.delete(target))
324
+ target.removeAttribute('data-native-ui-snapshot-prepared');
325
+ target.removeAttribute('data-native-ui-snapshot-visible');
326
+ target.removeAttribute(imageMarker);
327
+ target.removeAttribute('data-native-ui-snapshot-source');
328
+ };
329
+ const detach = () => {
330
+ presentation++;
331
+ win.cancelAnimationFrame(paintFrame);
332
+ win.cancelAnimationFrame(offsetFrame);
333
+ offsetFrame = 0;
334
+ offsets.clear();
335
+ paintFrame = 0;
336
+ handoffPending = false;
337
+ presented = false;
338
+ clearPrepared();
339
+ active?.sources.forEach((source) => {
340
+ owners.delete(source);
341
+ visibleOwners.delete(source);
342
+ });
343
+ rendered.forEach(clearTarget);
344
+ rendered.clear();
345
+ sheets.forEach(({ base, image }) => {
346
+ base.remove();
347
+ image.remove();
348
+ });
349
+ sheets.clear();
350
+ };
351
+ const abandon = (expected = active, afterPaint = ending) => {
352
+ if (active !== expected)
353
+ return;
354
+ const previous = new Set([active, cached]);
355
+ detach();
356
+ active = undefined;
357
+ cached = undefined;
358
+ ending = false;
359
+ win.clearTimeout(restoreTimeout);
360
+ previous.forEach((item) => dispose(item, afterPaint));
361
+ callbacks.wake();
362
+ };
363
+ const finishRestoration = () => {
364
+ if (!active)
365
+ return;
366
+ // A moved return invalidates the cached coordinates, but keeps the active
367
+ // visual until Native has taken ownership at its destination.
368
+ if (cached !== active) {
369
+ abandon();
370
+ return;
371
+ }
372
+ const image = active;
373
+ detach();
374
+ active = undefined;
375
+ ending = false;
376
+ win.clearTimeout(restoreTimeout);
377
+ // Retire only the temporary return copy. Keep the pre-interaction image
378
+ // available until a replacement finishes, including rapid overlay reopening.
379
+ // Native's update acknowledgement precedes WebKit's paint. Keep its return
380
+ // copy over live glass until the removed Web image has crossed a paint boundary.
381
+ // Flush this callback before another cover can reuse the same token.
382
+ cleanup('restored', image.data.token, true);
383
+ image.sources.forEach((source) => {
384
+ preparedOwners.add(source);
385
+ preparedSources.add(source);
386
+ });
387
+ render();
388
+ callbacks.wake();
389
+ };
390
+ const retireRestoration = () => {
391
+ if (ending)
392
+ finishRestoration();
393
+ pendingCleanup.forEach((run) => run());
394
+ };
395
+ const targetsFor = (image) => image.scopes.map((scope) => (validTarget(scope.element) ? scope.element : undefined));
396
+ const rendered = new Set();
397
+ const preparedKeys = new Map();
398
+ const offsets = new Map();
399
+ let offsetFrame = 0;
400
+ const updateOffsets = () => {
401
+ for (const [target, offset] of offsets) {
402
+ if (!target.isConnected)
403
+ continue;
404
+ const style = win.getComputedStyle(target);
405
+ const left = `${offset.x - (parseFloat(style.borderLeftWidth) || 0) + target.scrollLeft}px`;
406
+ const top = `${offset.y - (parseFloat(style.borderTopWidth) || 0) + target.scrollTop}px`;
407
+ if (offset.style.left !== left)
408
+ offset.style.setProperty('left', left, 'important');
409
+ if (offset.style.top !== top)
410
+ offset.style.setProperty('top', top, 'important');
411
+ }
412
+ };
413
+ const followBorderAnimation = () => {
414
+ if (offsetFrame || !active)
415
+ return;
416
+ const movingBorder = [...offsets.keys()].some((target) => target
417
+ .getAnimations?.()
418
+ .some((animation) => animation.playState === 'running' &&
419
+ animation.effect
420
+ ?.getKeyframes()
421
+ .some((frame) => ['borderLeftWidth', 'borderTopWidth', 'borderWidth'].some((property) => property in frame))));
422
+ if (movingBorder)
423
+ offsetFrame = win.requestAnimationFrame(() => {
424
+ offsetFrame = 0;
425
+ updateOffsets();
426
+ followBorderAnimation();
427
+ });
428
+ };
429
+ const render = () => {
430
+ const image = active ?? cached;
431
+ if (!image || ending)
432
+ return;
433
+ if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
434
+ invalidate();
435
+ return;
436
+ }
437
+ const targets = targetsFor(image);
438
+ if (active) {
439
+ if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
440
+ abandon(active, false);
441
+ return;
442
+ }
443
+ const next = new Set();
444
+ for (const [index, source] of image.targets.entries()) {
445
+ const key = preparedKeys.get(source);
446
+ const target = targets[index];
447
+ if (!key || !target?.isConnected)
448
+ continue;
449
+ target.setAttribute(imageMarker, key);
450
+ target.setAttribute('data-native-ui-snapshot-source', '');
451
+ target.toggleAttribute('data-native-ui-snapshot-visible', presented);
452
+ next.add(target);
453
+ }
454
+ for (const target of rendered)
455
+ if (!next.has(target))
456
+ clearTarget(target);
457
+ rendered.clear();
458
+ next.forEach((target) => rendered.add(target));
459
+ return;
460
+ }
461
+ preparedKeys.clear();
462
+ offsets.clear();
463
+ rendered.forEach(clearTarget);
464
+ rendered.clear();
465
+ // Reuse the image stylesheet. Presentation states belong to the runtime's
466
+ // permanent CSS, which survives cache replacement and image retirement.
467
+ const groups = new Map();
468
+ for (const target of new Set(targets)) {
469
+ if (!target)
470
+ continue;
471
+ rendered.add(target);
472
+ const sheet = sheetFor(rootFor(target));
473
+ let group = groups.get(sheet);
474
+ if (!group) {
475
+ group = { rules: [], positions: [] };
476
+ groups.set(sheet, group);
477
+ }
478
+ const { rules, positions } = group;
479
+ // A decoded clone stays concealed even if this cache is invalidated before
480
+ // the projection adapter publishes its next Native/Web ownership state.
481
+ if (target !== image.targets[targets.indexOf(target)] && !target.hasAttribute('data-native-ui-snapshot-prepared')) {
482
+ target.setAttribute('data-native-ui-snapshot-prepared', '');
483
+ preparedTargets.add(target);
484
+ }
485
+ target.toggleAttribute('data-native-ui-snapshot-visible', !!active || !!callbacks.opaqueUnderlay);
486
+ if (active)
487
+ target.setAttribute('data-native-ui-snapshot-source', '');
488
+ const indices = targets.flatMap((owner, index) => (owner === target ? [index] : []));
489
+ const style = win.getComputedStyle(target);
490
+ {
491
+ const space = coordinateSpace(target, win, style);
492
+ indices.forEach((index) => {
493
+ const part = image.data.parts[index];
494
+ if (callbacks.opaqueUnderlay && image.frames[index])
495
+ return;
496
+ image.frames[index] = {
497
+ x: callbacks.opaqueUnderlay ? 0 : (part.x - space.x) / space.scaleX,
498
+ y: callbacks.opaqueUnderlay ? 0 : (part.y - space.y) / space.scaleY,
499
+ width: part.width / space.scaleX,
500
+ height: part.height / space.scaleY,
501
+ };
502
+ });
503
+ }
504
+ const frames = indices.map((index) => image.frames[index]);
505
+ const x = Math.min(...frames.map((frame) => frame.x));
506
+ const y = Math.min(...frames.map((frame) => frame.y));
507
+ const right = Math.max(...frames.map((frame) => frame.x + frame.width));
508
+ const bottom = Math.max(...frames.map((frame) => frame.y + frame.height));
509
+ const left = (parseFloat(style.borderLeftWidth) || 0) - target.scrollLeft;
510
+ const top = (parseFloat(style.borderTopWidth) || 0) - target.scrollTop;
511
+ const key = `${imageIdentity}-${indices[0]}`;
512
+ indices.forEach((index) => preparedKeys.set(image.targets[index], key));
513
+ target.setAttribute(imageMarker, key);
514
+ rules.push(`[${imageMarker}="${key}"][data-native-ui-snapshot-visible] { ${style.position === 'static' ? 'position:relative!important;' : ''} }`);
515
+ const backgrounds = indices.map((index) => {
516
+ const frame = image.frames[index];
517
+ return `url(${JSON.stringify(image.images[index].src)}) ${frame.x - x}px ${frame.y - y}px / ${frame.width}px ${frame.height}px no-repeat`;
518
+ });
519
+ const declaration = doc.createElement('span').style;
520
+ declaration.cssText = `left:${x - left}px;top:${y - top}px;width:${right - x}px;height:${bottom - y}px;background:${backgrounds.join(',')};`;
521
+ for (const property of Array.from(declaration))
522
+ declaration.setProperty(property, declaration.getPropertyValue(property), 'important');
523
+ // Outrank every manager's shared reset, even when another manager starts later.
524
+ rules.push(`[${imageMarker}="${key}"][${imageMarker}][${imageMarker}]::before { ${declaration.cssText} }`);
525
+ positions.push({ target, x, y });
526
+ }
527
+ image.parents = image.scopes.map((scope) => scope.element.parentNode);
528
+ for (const [sheet, { rules, positions }] of groups) {
529
+ sheet.textContent = rules.join('\n');
530
+ positions.forEach(({ target, x, y }, index) => offsets.set(target, {
531
+ x,
532
+ y,
533
+ style: sheet.sheet.cssRules[index * 2 + 1].style,
534
+ }));
535
+ }
536
+ };
537
+ const begin = () => {
538
+ retireRestoration();
539
+ if (stopped || destination === 'none' || active)
540
+ return;
541
+ const image = cached;
542
+ if (!image ||
543
+ image.viewport !== viewport() ||
544
+ !image.sources.every(validSource) ||
545
+ (callbacks.current && !callbacks.current(image.targets)) ||
546
+ image.targets.some((source) => !preparedKeys.has(source)) ||
547
+ targetsFor(image).some((target) => !target?.isConnected) ||
548
+ [...rendered].some((target) => !target.isConnected))
549
+ return;
550
+ // Reparenting is renderer-owned. Only that case needs a fresh coordinate conversion;
551
+ // ordinary presentation keeps the prepared layout off the critical path.
552
+ if (image.scopes.some((scope, index) => scope.element.parentNode !== image.parents[index]))
553
+ render();
554
+ updateOffsets();
555
+ // A cached image may span multiple presentations; bridge replies may not.
556
+ const currentPresentation = ++presentation;
557
+ const isCurrent = () => presentation === currentPresentation;
558
+ handoffPending = true;
559
+ presented = !!callbacks.opaqueUnderlay;
560
+ active = image;
561
+ // Reuse decoded pixels; no capture, bridge acknowledgement or animation wait.
562
+ generation++;
563
+ win.clearTimeout(timer);
564
+ image.sources.forEach((source) => {
565
+ owners.add(source);
566
+ });
567
+ rendered.forEach((target) => {
568
+ target.setAttribute('data-native-ui-snapshot-source', '');
569
+ target.toggleAttribute('data-native-ui-snapshot-visible', presented);
570
+ });
571
+ // Reconciliation may continue after one frame even if the optional bridge reply
572
+ // is delayed. The ordered native transport processes cover before that update.
573
+ paintFrame = win.requestAnimationFrame(() => {
574
+ paintFrame = 0;
575
+ if (!isCurrent())
576
+ return;
577
+ handoffPending = false;
578
+ callbacks.wake();
579
+ });
580
+ void bounded(call({ action: 'cover', token: image.data.token, ...(callbacks.transient ? { transient: true } : {}) }))
581
+ .then((result) => {
582
+ if (!isCurrent())
583
+ return;
584
+ handoffPending = false;
585
+ if (result?.shown !== true) {
586
+ abandon(image, false);
587
+ return;
588
+ }
589
+ // Live translucent Native must be hidden before its composited Web image
590
+ // becomes visible. Native's prepared entry copy spans this bridge interval.
591
+ if (!ending) {
592
+ presented = true;
593
+ image.sources.forEach((source) => visibleOwners.add(source));
594
+ render();
595
+ updateOffsets();
596
+ followBorderAnimation();
597
+ }
598
+ win.cancelAnimationFrame(paintFrame);
599
+ paintFrame = win.requestAnimationFrame(() => {
600
+ paintFrame = 0;
601
+ if (!isCurrent())
602
+ return;
603
+ void call({ action: 'painted', token: image.data.token }).catch(() => {
604
+ if (isCurrent())
605
+ abandon(image, false);
606
+ });
607
+ });
608
+ callbacks.wake();
609
+ })
610
+ .catch(() => {
611
+ if (isCurrent())
612
+ abandon(image, false);
613
+ });
614
+ };
615
+ const end = () => {
616
+ if (!active || stopped || ending)
617
+ return;
618
+ // A return belongs to the captured surface, never to the modal exposed beneath it.
619
+ if (!active.sources.every(validSource) ||
620
+ targetsFor(active).some((target) => !target) ||
621
+ (callbacks.current && !callbacks.current(active.targets))) {
622
+ abandon(active, false);
623
+ return;
624
+ }
625
+ if (callbacks.blocked())
626
+ return;
627
+ if (callbacks.transient) {
628
+ const image = active;
629
+ const targets = targetsFor(image);
630
+ if (targets.some((target) => !target?.isConnected)) {
631
+ abandon(image, false);
632
+ return;
633
+ }
634
+ const parts = targets.map((target, index) => {
635
+ const space = coordinateSpace(target, win);
636
+ const frame = image.frames[index];
637
+ return {
638
+ ...image.data.parts[index],
639
+ x: space.x + frame.x * space.scaleX,
640
+ y: space.y + frame.y * space.scaleY,
641
+ width: frame.width * space.scaleX,
642
+ height: frame.height * space.scaleY,
643
+ };
644
+ });
645
+ if (parts.some((part, index) => ['x', 'y', 'width', 'height'].some((key) => part[key] !== image.data.parts[index][key]))) {
646
+ if (callbacks.opaqueUnderlay)
647
+ image.data = { ...image.data, parts };
648
+ else
649
+ cached = undefined;
650
+ }
651
+ // Both requests use the same ordered bridge. Reprojection need not await this optional copy.
652
+ void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => { });
653
+ }
654
+ restoreRevision = callbacks.revision?.() ?? latest?.revision ?? 0;
655
+ ending = true;
656
+ active.sources.forEach((source) => owners.delete(source));
657
+ restoreTimeout = win.setTimeout(() => {
658
+ if (ending)
659
+ abandon(active, false);
660
+ }, 250);
661
+ callbacks.wake();
662
+ };
663
+ const updated = (token, restoredIDs, revision) => {
664
+ if (!ending || !active || revision === undefined || revision <= restoreRevision)
665
+ return;
666
+ if (!callbacks.transient && token)
667
+ return;
668
+ if (!active.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id))))
669
+ return;
670
+ // Retire the Web image at the native restore acknowledgement, not after
671
+ // another complete reconciliation/paint cycle over live glass.
672
+ finishRestoration();
673
+ schedule();
674
+ };
675
+ const invalidate = () => {
676
+ pendingCleanup.forEach((run) => run());
677
+ generation++;
678
+ win.clearTimeout(timer);
679
+ const previous = cached;
680
+ cached = undefined;
681
+ dispose(previous);
682
+ if (active)
683
+ abandon(active, false);
684
+ else {
685
+ detach();
686
+ preparedKeys.clear();
687
+ win.dispatchEvent(new Event(sourcesChangedEvent));
688
+ }
689
+ };
690
+ let eventListener;
691
+ try {
692
+ if (callbacks.events)
693
+ eventListener = await bindProjectionEvents(callbacks.events, (next, updated) => {
694
+ if (stopped)
695
+ return;
696
+ if (next)
697
+ destination = next;
698
+ // Native hide has already happened. Retire images and invalidate pending captures,
699
+ // without calling the plugin's public hide method or disturbing its fallback DOM.
700
+ if (destination === 'none') {
701
+ invalidate();
702
+ return;
703
+ }
704
+ if (next === 'web')
705
+ begin();
706
+ if (next === 'native') {
707
+ end();
708
+ schedule();
709
+ }
710
+ if (updated)
711
+ changed();
712
+ });
713
+ }
714
+ catch (error) {
715
+ stopped = true;
716
+ invalidate();
717
+ await bounded(Promise.resolve(listener.remove())).catch(() => { });
718
+ throw error;
719
+ }
720
+ return {
721
+ get owners() {
722
+ return [...owners];
723
+ },
724
+ get visibleOwners() {
725
+ return [...visibleOwners];
726
+ },
727
+ get preparedOwners() {
728
+ return [...preparedOwners];
729
+ },
730
+ consider,
731
+ changed,
732
+ moved: () => {
733
+ updateOffsets();
734
+ followBorderAnimation();
735
+ },
736
+ get token() {
737
+ return ending ? undefined : active?.data.token;
738
+ },
739
+ begin,
740
+ get restoring() {
741
+ return ending;
742
+ },
743
+ get handoffPending() {
744
+ return handoffPending;
745
+ },
746
+ retireRestoration,
747
+ end,
748
+ updated,
749
+ invalidate,
750
+ async destroy() {
751
+ stopped = true;
752
+ invalidate();
753
+ await Promise.allSettled([
754
+ bounded(Promise.resolve().then(() => listener?.remove())),
755
+ bounded(Promise.resolve().then(() => eventListener?.remove())),
756
+ ]);
757
+ },
758
+ };
759
+ };
760
+ /** One existing fallback DOM, with no separate document or rendering destination. */
761
+ export const createWebProjection = async (scope, transport, options) => {
762
+ const engine = await createProjectionEngine(scope, transport, options);
763
+ const consider = engine.consider;
764
+ return Object.assign(engine, {
765
+ consider: (revision, sources) => consider(revision, sources.map((source) => ({ ...source, scope }))),
766
+ });
767
+ };
768
+ /** A single Native transaction for multiple fixed fallback DOMs. */
769
+ export const createWebProjectionBatch = async (transport, options) => {
770
+ let engine;
771
+ let initializing;
772
+ let latest;
773
+ let stopped = false;
774
+ let batchDocument;
775
+ return {
776
+ get owners() {
777
+ return engine?.owners ?? [];
778
+ },
779
+ get visibleOwners() {
780
+ return engine?.visibleOwners ?? [];
781
+ },
782
+ get preparedOwners() {
783
+ return engine?.preparedOwners ?? [];
784
+ },
785
+ get token() {
786
+ return engine?.token;
787
+ },
788
+ get restoring() {
789
+ return engine?.restoring ?? false;
790
+ },
791
+ get handoffPending() {
792
+ return engine?.handoffPending ?? false;
793
+ },
794
+ consider(revision, sources) {
795
+ if (stopped)
796
+ return;
797
+ const doc = batchDocument ?? sources[0]?.scope.element.ownerDocument;
798
+ if (doc &&
799
+ sources.some((source) => source.scope.element.ownerDocument !== doc ||
800
+ [source.element, ...(source.sources ?? [])].some((element) => element.ownerDocument !== doc)))
801
+ throw new TypeError('All projection sources and fallback scopes must share one Document');
802
+ batchDocument = doc;
803
+ latest = { revision, sources };
804
+ if (engine) {
805
+ engine.consider(revision, sources);
806
+ return;
807
+ }
808
+ if (initializing || !sources.length)
809
+ return;
810
+ initializing = createProjectionEngine(sources[0].scope, transport, options)
811
+ .then(async (created) => {
812
+ if (stopped) {
813
+ await created.destroy();
814
+ return;
815
+ }
816
+ engine = created;
817
+ if (latest)
818
+ engine.consider(latest.revision, latest.sources);
819
+ })
820
+ .catch(() => {
821
+ initializing = undefined;
822
+ options.wake();
823
+ });
824
+ },
825
+ changed: (owner) => engine?.changed(owner),
826
+ moved: () => engine?.moved(),
827
+ begin: () => engine?.begin(),
828
+ end: () => engine?.end(),
829
+ updated: (token, ids, revision) => engine?.updated(token, ids, revision),
830
+ retireRestoration: () => engine?.retireRestoration(),
831
+ invalidate: () => engine?.invalidate(),
832
+ async destroy() {
833
+ stopped = true;
834
+ await initializing;
835
+ await engine?.destroy();
836
+ },
837
+ };
838
+ };