@webview-native-ui/engine 1.0.2-0 → 1.0.3
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/dist/projection/web.d.ts +1 -1
- package/dist/projection/web.js +116 -94
- package/dist/types.d.ts +1 -0
- package/docs/advanced/web-projection.md +15 -2
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -1
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -3
- package/package.json +1 -1
- package/src/projection/web.ts +119 -93
- package/src/types.ts +1 -0
package/src/projection/web.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface WebProjectionOptions {
|
|
|
26
26
|
captureRevision?(): number;
|
|
27
27
|
/** Select this surface's sources, e.g. the underlying page or the active overlay. */
|
|
28
28
|
eligible?(source: Candidate): boolean;
|
|
29
|
-
/**
|
|
29
|
+
/** Validate the captured source set, including composite sources, against its owning surface. */
|
|
30
30
|
current?(sources: HTMLElement[]): boolean;
|
|
31
31
|
/** Temporary movement handoff: rebase return pixels to the current display DOM. */
|
|
32
32
|
transient?: boolean;
|
|
@@ -47,6 +47,10 @@ interface Prepared {
|
|
|
47
47
|
layout: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** One pixel handoff; requests and renderer source ownership remain independent. */
|
|
51
|
+
type ImageHandoff =
|
|
52
|
+
{ phase: 'covering' | 'image'; image: Prepared } | { phase: 'restoring'; image: Prepared; visible: boolean; revision: number };
|
|
53
|
+
|
|
50
54
|
const layout = ({ control: c }: Candidate, positionIndependent = false) => [
|
|
51
55
|
c.id,
|
|
52
56
|
c.kind,
|
|
@@ -68,6 +72,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
68
72
|
const doc = scope.element.ownerDocument;
|
|
69
73
|
const projectionRequest = getProjectionPause(doc);
|
|
70
74
|
let locallyRequested = false;
|
|
75
|
+
const imageRequested = () => locallyRequested || projectionRequest.isPaused();
|
|
71
76
|
const win = doc.defaultView!;
|
|
72
77
|
// Native tokens are local to a transport; never use them as global CSS identities.
|
|
73
78
|
const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
|
|
@@ -86,11 +91,15 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
86
91
|
const call = transport.send.bind(transport);
|
|
87
92
|
const imageMarker = 'data-native-ui-snapshot';
|
|
88
93
|
const sourcesChangedEvent = `${scope.namespace}:sources-change`;
|
|
89
|
-
const
|
|
90
|
-
visibleOwners = new Set<HTMLElement>(),
|
|
91
|
-
preparedOwners = new Set<HTMLElement>();
|
|
94
|
+
const preparedOwners = new Set<HTMLElement>();
|
|
92
95
|
const scopeIDs = new WeakMap<WebProjectionScope, number>();
|
|
93
96
|
let scopeSerial = 0;
|
|
97
|
+
const sourceIDs = new WeakMap<HTMLElement, number>();
|
|
98
|
+
let sourceSerial = 0;
|
|
99
|
+
const sourceIdentity = (source: HTMLElement) => {
|
|
100
|
+
if (!sourceIDs.has(source)) sourceIDs.set(source, ++sourceSerial);
|
|
101
|
+
return sourceIDs.get(source)!;
|
|
102
|
+
};
|
|
94
103
|
const signature = (candidates: Candidate[]) =>
|
|
95
104
|
JSON.stringify(
|
|
96
105
|
candidates.map((candidate) => {
|
|
@@ -99,7 +108,11 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
99
108
|
const relative = origin
|
|
100
109
|
? { ...candidate, control: { ...candidate.control, x: candidate.control.x - origin.x, y: candidate.control.y - origin.y } }
|
|
101
110
|
: candidate;
|
|
102
|
-
return [
|
|
111
|
+
return [
|
|
112
|
+
scopeIDs.get(candidate.scope),
|
|
113
|
+
layout(relative, !!callbacks.opaqueUnderlay),
|
|
114
|
+
(candidate.sources ?? [candidate.element]).map(sourceIdentity),
|
|
115
|
+
];
|
|
103
116
|
}),
|
|
104
117
|
);
|
|
105
118
|
const sheets = new Map<Document | ShadowRoot, HTMLStyleElement>();
|
|
@@ -147,16 +160,16 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
147
160
|
};
|
|
148
161
|
const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
|
|
149
162
|
let handoffPending = false;
|
|
150
|
-
let presented = false;
|
|
151
163
|
let paintFrame = 0;
|
|
152
164
|
let presentation = 0;
|
|
153
165
|
let cached: Prepared | undefined;
|
|
154
166
|
let retiring: object | undefined;
|
|
155
|
-
let
|
|
167
|
+
let handoff: ImageHandoff | undefined;
|
|
168
|
+
const restoring = () => handoff?.phase === 'restoring';
|
|
169
|
+
const imageVisible = () =>
|
|
170
|
+
!!handoff && (!!callbacks.opaqueUnderlay || handoff.phase === 'image' || (handoff.phase === 'restoring' && handoff.visible));
|
|
156
171
|
let touching = false;
|
|
157
|
-
let ending = false;
|
|
158
172
|
let restoreTimeout = 0;
|
|
159
|
-
let restoreRevision = 0;
|
|
160
173
|
let generation = 0;
|
|
161
174
|
let timer = 0;
|
|
162
175
|
let visualRevision = 0;
|
|
@@ -168,7 +181,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
168
181
|
const targets = new Set(latest?.candidates.map((candidate) => candidate.scope.element));
|
|
169
182
|
for (const [target, pool] of imagePool) {
|
|
170
183
|
const reusable = !stopped && target.isConnected && targets.has(target);
|
|
171
|
-
if (reusable && (
|
|
184
|
+
if (reusable && (handoff || handoffPending)) continue;
|
|
172
185
|
const limit = reusable ? 2 * Math.max(1, cached?.scopes.filter((scope) => scope.element === target).length ?? 0) : 0;
|
|
173
186
|
let spare = Math.max(0, limit - pool.filter((image) => leasedImages.has(image)).length);
|
|
174
187
|
const retained = pool.filter((image) => {
|
|
@@ -181,7 +194,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
181
194
|
}
|
|
182
195
|
const liveRoots = new Set<Node>([
|
|
183
196
|
...(latest?.candidates.map((candidate) => candidate.scope.element.getRootNode()) ?? []),
|
|
184
|
-
...(
|
|
197
|
+
...(handoff?.image.scopes.map((scope) => scope.element.getRootNode()) ?? []),
|
|
185
198
|
...(cached?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
|
|
186
199
|
...[...leasedImages].map((image) => image.getRootNode()),
|
|
187
200
|
...[...shadowHosts.values()].flatMap((hosts) => [...hosts].map((host) => host.shadowRoot!)),
|
|
@@ -209,7 +222,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
209
222
|
preparedSources.clear();
|
|
210
223
|
};
|
|
211
224
|
const dispose = (item: Prepared | undefined, afterPaint = false) => {
|
|
212
|
-
if (!item || item === cached || item ===
|
|
225
|
+
if (!item || item === cached || item === handoff?.image) return;
|
|
213
226
|
item.images.forEach((image) => {
|
|
214
227
|
retireImage(image);
|
|
215
228
|
});
|
|
@@ -235,7 +248,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
235
248
|
destination !== 'native' ||
|
|
236
249
|
flight ||
|
|
237
250
|
touching ||
|
|
238
|
-
|
|
251
|
+
handoff ||
|
|
239
252
|
callbacks.blocked() ||
|
|
240
253
|
!latest ||
|
|
241
254
|
(cached?.revision === latest.revision && preparedVisualRevision === visualRevision)
|
|
@@ -320,7 +333,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
320
333
|
stopped ||
|
|
321
334
|
destination !== 'native' ||
|
|
322
335
|
touching ||
|
|
323
|
-
|
|
336
|
+
handoff ||
|
|
324
337
|
callbacks.blocked() ||
|
|
325
338
|
generation !== stamp ||
|
|
326
339
|
viewport() !== size ||
|
|
@@ -334,7 +347,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
334
347
|
const prepared: Prepared = {
|
|
335
348
|
data: result as CoveredSnapshot,
|
|
336
349
|
images,
|
|
337
|
-
sources: Array.from(new Set(selected.flatMap((item) =>
|
|
350
|
+
sources: Array.from(new Set(selected.flatMap((item) => item.sources ?? [item.element]))),
|
|
338
351
|
targets: partSources.map((source) => source.element),
|
|
339
352
|
scopes: partSources.map((source) => source.scope),
|
|
340
353
|
parents: partSources.map((source) => source.scope.element.parentNode),
|
|
@@ -371,7 +384,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
371
384
|
preparedVisualRevision = visual;
|
|
372
385
|
adopted = true;
|
|
373
386
|
dispose(previous);
|
|
374
|
-
if (
|
|
387
|
+
if (imageRequested()) present();
|
|
375
388
|
} catch {
|
|
376
389
|
// Preparing a visual is optional. Preserve the previous image or the Web fallback.
|
|
377
390
|
} finally {
|
|
@@ -388,7 +401,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
388
401
|
// available while their replacement is being rendered and decoded.
|
|
389
402
|
const schedule = () => {
|
|
390
403
|
win.clearTimeout(timer);
|
|
391
|
-
if (!stopped && destination === 'native' && !
|
|
404
|
+
if (!stopped && destination === 'native' && !handoff && !touching && !callbacks.blocked())
|
|
392
405
|
timer = win.setTimeout(() => void prepare(), captureDelayMs);
|
|
393
406
|
};
|
|
394
407
|
let consideredLayout: string | undefined;
|
|
@@ -404,7 +417,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
404
417
|
// Overlay retirement is not a new source layout. Preserve the pre-overlay
|
|
405
418
|
// image until the presentation path has chosen whether to use it.
|
|
406
419
|
if (callbacks.blocked()) return;
|
|
407
|
-
if (!
|
|
420
|
+
if (!handoff && cached && cached.layout !== signature(select(candidates))) invalidate();
|
|
408
421
|
const nextLayout = signature(candidates);
|
|
409
422
|
if (consideredLayout !== nextLayout) {
|
|
410
423
|
consideredLayout = nextLayout;
|
|
@@ -420,13 +433,17 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
420
433
|
// A pre-interaction state image is reusable; an image from another scroll
|
|
421
434
|
// position/clip is not. Invalidate only the surface containing that owner.
|
|
422
435
|
if (
|
|
423
|
-
!
|
|
436
|
+
!handoff &&
|
|
424
437
|
cached &&
|
|
425
438
|
scrollOwner &&
|
|
426
|
-
latest?.candidates.some(
|
|
439
|
+
latest?.candidates.some(
|
|
440
|
+
(candidate) =>
|
|
441
|
+
candidate.scrollElement === scrollOwner &&
|
|
442
|
+
(candidate.sources ?? [candidate.element]).some((source) => cached!.sources.includes(source)),
|
|
443
|
+
)
|
|
427
444
|
)
|
|
428
445
|
invalidate();
|
|
429
|
-
if (
|
|
446
|
+
if (handoff) {
|
|
430
447
|
updateOffsets();
|
|
431
448
|
}
|
|
432
449
|
visualRevision++;
|
|
@@ -461,12 +478,8 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
461
478
|
offsets.clear();
|
|
462
479
|
paintFrame = 0;
|
|
463
480
|
handoffPending = false;
|
|
464
|
-
presented = false;
|
|
465
481
|
clearPrepared();
|
|
466
|
-
|
|
467
|
-
owners.delete(source);
|
|
468
|
-
visibleOwners.delete(source);
|
|
469
|
-
});
|
|
482
|
+
handoff = undefined;
|
|
470
483
|
rendered.forEach(clearTarget);
|
|
471
484
|
rendered.clear();
|
|
472
485
|
sheets.forEach((style, root) => {
|
|
@@ -476,38 +489,31 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
476
489
|
}
|
|
477
490
|
});
|
|
478
491
|
};
|
|
479
|
-
const abandon = (expected =
|
|
480
|
-
if (
|
|
481
|
-
const previous = new Set([
|
|
492
|
+
const abandon = (expected = handoff?.image, afterPaint = restoring()) => {
|
|
493
|
+
if (handoff?.image !== expected) return;
|
|
494
|
+
const previous = new Set([handoff?.image, cached]);
|
|
482
495
|
detach();
|
|
483
|
-
active = undefined;
|
|
484
496
|
cached = undefined;
|
|
485
|
-
ending = false;
|
|
486
497
|
win.clearTimeout(restoreTimeout);
|
|
487
498
|
previous.forEach((item) => dispose(item, afterPaint));
|
|
488
499
|
callbacks.wake();
|
|
489
500
|
};
|
|
490
501
|
const finishRestoration = () => {
|
|
491
|
-
if (!
|
|
502
|
+
if (!handoff) return;
|
|
492
503
|
// A moved return invalidates the cached coordinates, but keeps the active
|
|
493
504
|
// visual until Native has taken ownership at its destination.
|
|
494
|
-
if (cached !==
|
|
505
|
+
if (cached !== handoff?.image) {
|
|
495
506
|
abandon();
|
|
496
507
|
return;
|
|
497
508
|
}
|
|
498
|
-
const image =
|
|
509
|
+
const image = handoff.image;
|
|
499
510
|
presentation++;
|
|
500
511
|
win.cancelAnimationFrame(paintFrame);
|
|
501
512
|
paintFrame = 0;
|
|
502
|
-
handoffPending =
|
|
503
|
-
image.sources.forEach((source) => {
|
|
504
|
-
owners.delete(source);
|
|
505
|
-
visibleOwners.delete(source);
|
|
506
|
-
});
|
|
513
|
+
handoffPending = false;
|
|
507
514
|
rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source'));
|
|
508
515
|
showImages(image, !!callbacks.opaqueUnderlay);
|
|
509
|
-
|
|
510
|
-
ending = false;
|
|
516
|
+
handoff = undefined;
|
|
511
517
|
win.clearTimeout(restoreTimeout);
|
|
512
518
|
// Retire only the temporary return copy. Keep the pre-interaction image
|
|
513
519
|
// available until a replacement finishes, including rapid overlay reopening.
|
|
@@ -522,7 +528,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
522
528
|
callbacks.wake();
|
|
523
529
|
};
|
|
524
530
|
const retireRestoration = () => {
|
|
525
|
-
if (
|
|
531
|
+
if (restoring()) finishRestoration();
|
|
526
532
|
pendingCleanup.forEach((run) => run());
|
|
527
533
|
};
|
|
528
534
|
const targetsFor = (image: Prepared) => image.scopes.map((scope) => (validTarget(scope.element) ? scope.element : undefined));
|
|
@@ -568,16 +574,16 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
568
574
|
if (element.style.getPropertyValue(property) !== next) element.style.setProperty(property, next, 'important');
|
|
569
575
|
}
|
|
570
576
|
};
|
|
571
|
-
const render = (image =
|
|
572
|
-
if (!image ||
|
|
577
|
+
const render = (image = handoff?.image ?? cached) => {
|
|
578
|
+
if (!image || restoring()) return false;
|
|
573
579
|
if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
|
|
574
580
|
invalidate();
|
|
575
581
|
return false;
|
|
576
582
|
}
|
|
577
583
|
const targets = targetsFor(image);
|
|
578
|
-
if (
|
|
584
|
+
if (handoff) {
|
|
579
585
|
if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
|
|
580
|
-
abandon(
|
|
586
|
+
abandon(handoff.image, false);
|
|
581
587
|
return false;
|
|
582
588
|
}
|
|
583
589
|
const next = new Set<HTMLElement>();
|
|
@@ -592,7 +598,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
592
598
|
for (const target of rendered) if (!next.has(target)) clearTarget(target);
|
|
593
599
|
rendered.clear();
|
|
594
600
|
next.forEach((target) => rendered.add(target));
|
|
595
|
-
showImages(image,
|
|
601
|
+
showImages(image, imageVisible());
|
|
596
602
|
return true;
|
|
597
603
|
}
|
|
598
604
|
imagePool.forEach((pool) =>
|
|
@@ -661,13 +667,13 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
661
667
|
|
|
662
668
|
const present = (force = false) => {
|
|
663
669
|
retireRestoration();
|
|
664
|
-
if (stopped || destination === 'none' || (
|
|
665
|
-
const image =
|
|
670
|
+
if (stopped || destination === 'none' || (handoff && !force)) return;
|
|
671
|
+
const image = handoff?.image ?? cached;
|
|
666
672
|
if (
|
|
667
673
|
!image ||
|
|
668
674
|
image.viewport !== viewport() ||
|
|
669
675
|
!image.sources.every(validSource) ||
|
|
670
|
-
(callbacks.current && !callbacks.current(image.
|
|
676
|
+
(callbacks.current && !callbacks.current(image.sources)) ||
|
|
671
677
|
image.targets.some((source) => !preparedKeys.has(source)) ||
|
|
672
678
|
targetsFor(image).some((target) => !target?.isConnected) ||
|
|
673
679
|
[...rendered].some((target) => !target.isConnected)
|
|
@@ -681,16 +687,12 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
681
687
|
const currentPresentation = ++presentation;
|
|
682
688
|
const isCurrent = () => presentation === currentPresentation;
|
|
683
689
|
handoffPending = true;
|
|
684
|
-
|
|
685
|
-
active = image;
|
|
690
|
+
handoff = { phase: 'covering', image };
|
|
686
691
|
// Reuse decoded pixels; no capture, bridge acknowledgement or animation wait.
|
|
687
692
|
generation++;
|
|
688
693
|
win.clearTimeout(timer);
|
|
689
|
-
image.sources.forEach((source) => {
|
|
690
|
-
owners.add(source);
|
|
691
|
-
});
|
|
692
694
|
rendered.forEach((target) => target.setAttribute('data-native-ui-snapshot-source', ''));
|
|
693
|
-
showImages(image,
|
|
695
|
+
showImages(image, imageVisible());
|
|
694
696
|
// Reconciliation may continue after one frame even if the optional bridge reply
|
|
695
697
|
// is delayed. The ordered native transport processes cover before that update.
|
|
696
698
|
paintFrame = win.requestAnimationFrame(() => {
|
|
@@ -715,23 +717,26 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
715
717
|
}
|
|
716
718
|
// Live translucent Native must be hidden before its composited Web image
|
|
717
719
|
// becomes visible. Native's prepared entry copy spans this bridge interval.
|
|
718
|
-
if (!
|
|
719
|
-
|
|
720
|
-
image.sources.forEach((source) => visibleOwners.add(source));
|
|
720
|
+
if (!restoring()) {
|
|
721
|
+
handoff = { phase: 'image', image };
|
|
721
722
|
render();
|
|
722
723
|
updateOffsets();
|
|
723
724
|
}
|
|
724
725
|
win.cancelAnimationFrame(paintFrame);
|
|
726
|
+
// rAF callbacks run before paint. Cross a paint boundary before retiring
|
|
727
|
+
// Native's entry copy, keeping it over WebKit's compositing interval.
|
|
725
728
|
paintFrame = win.requestAnimationFrame(() => {
|
|
726
|
-
paintFrame =
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
729
|
+
paintFrame = win.requestAnimationFrame(() => {
|
|
730
|
+
paintFrame = 0;
|
|
731
|
+
if (!isCurrent()) return;
|
|
732
|
+
void call({ action: 'painted', token: image.data.token })
|
|
733
|
+
.then((result) => {
|
|
734
|
+
if (isCurrent() && result?.shown === false) abandon(image, false);
|
|
735
|
+
})
|
|
736
|
+
.catch(() => {
|
|
737
|
+
if (isCurrent()) abandon(image, false);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
735
740
|
});
|
|
736
741
|
callbacks.wake();
|
|
737
742
|
})
|
|
@@ -740,20 +745,20 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
740
745
|
});
|
|
741
746
|
};
|
|
742
747
|
const restore = () => {
|
|
743
|
-
if (!
|
|
748
|
+
if (!handoff || stopped || restoring()) return;
|
|
744
749
|
// A return belongs to the captured surface, never to the modal exposed beneath it.
|
|
745
750
|
if (
|
|
746
|
-
!
|
|
747
|
-
targetsFor(
|
|
748
|
-
(callbacks.current && !callbacks.current(
|
|
751
|
+
!handoff.image.sources.every(validSource) ||
|
|
752
|
+
targetsFor(handoff.image).some((target) => !target) ||
|
|
753
|
+
(callbacks.current && !callbacks.current(handoff.image.sources))
|
|
749
754
|
) {
|
|
750
|
-
abandon(
|
|
755
|
+
abandon(handoff.image, false);
|
|
751
756
|
return;
|
|
752
757
|
}
|
|
753
758
|
if (callbacks.blocked()) return;
|
|
754
759
|
updateOffsets();
|
|
755
760
|
if (callbacks.transient) {
|
|
756
|
-
const image =
|
|
761
|
+
const image = handoff.image;
|
|
757
762
|
const targets = targetsFor(image);
|
|
758
763
|
if (targets.some((target) => !target?.isConnected)) {
|
|
759
764
|
abandon(image, false);
|
|
@@ -764,25 +769,37 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
764
769
|
// image until its replacement is ready; resizing still invalidates it.
|
|
765
770
|
if (
|
|
766
771
|
callbacks.opaqueUnderlay ||
|
|
767
|
-
parts.every((part, index) =>
|
|
772
|
+
parts.every((part, index) => {
|
|
773
|
+
const original = image.data.parts[index];
|
|
774
|
+
const scale = win.devicePixelRatio || 1;
|
|
775
|
+
// Transformed DOMRects can differ by floating-point noise. Reuse pixels
|
|
776
|
+
// only when their raster dimensions still match at the display scale.
|
|
777
|
+
return (
|
|
778
|
+
Math.round(part.width * scale) === Math.round(original.width * scale) &&
|
|
779
|
+
Math.round(part.height * scale) === Math.round(original.height * scale)
|
|
780
|
+
);
|
|
781
|
+
})
|
|
768
782
|
)
|
|
769
783
|
image.data = { ...image.data, parts };
|
|
770
784
|
else cached = undefined;
|
|
771
785
|
// Both requests use the same ordered bridge. Reprojection need not await this optional copy.
|
|
772
786
|
void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => {});
|
|
773
787
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
788
|
+
handoff = {
|
|
789
|
+
phase: 'restoring',
|
|
790
|
+
image: handoff.image,
|
|
791
|
+
visible: handoff.phase === 'image',
|
|
792
|
+
revision: callbacks.revision?.() ?? latest?.revision ?? 0,
|
|
793
|
+
};
|
|
777
794
|
restoreTimeout = win.setTimeout(() => {
|
|
778
|
-
if (
|
|
795
|
+
if (handoff?.phase === 'restoring') abandon(handoff.image, false);
|
|
779
796
|
}, 250);
|
|
780
797
|
callbacks.wake();
|
|
781
798
|
};
|
|
782
799
|
const updated = (token: string | undefined, restoredIDs?: string[], revision?: number) => {
|
|
783
|
-
if (
|
|
800
|
+
if (handoff?.phase !== 'restoring' || revision === undefined || revision <= handoff.revision) return;
|
|
784
801
|
if (!callbacks.transient && token) return;
|
|
785
|
-
if (!
|
|
802
|
+
if (!handoff.image.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id)))) return;
|
|
786
803
|
// Retire the Web image at the native restore acknowledgement, not after
|
|
787
804
|
// another complete reconciliation/paint cycle over live glass.
|
|
788
805
|
finishRestoration();
|
|
@@ -795,7 +812,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
795
812
|
const previous = cached;
|
|
796
813
|
cached = undefined;
|
|
797
814
|
if (nativeHidden || stopped) retiring = undefined;
|
|
798
|
-
if (callbacks.retainPreparedUntilRelease && !
|
|
815
|
+
if (callbacks.retainPreparedUntilRelease && !handoff && !nativeHidden && !stopped) {
|
|
799
816
|
if (previous) {
|
|
800
817
|
const pending = {};
|
|
801
818
|
retiring = pending;
|
|
@@ -807,7 +824,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
807
824
|
});
|
|
808
825
|
if (retiring !== pending) return;
|
|
809
826
|
retiring = undefined;
|
|
810
|
-
if (cached ||
|
|
827
|
+
if (cached || handoff) return;
|
|
811
828
|
// Another session can already own the same host; retire only our own DOM markers.
|
|
812
829
|
for (const target of rendered) {
|
|
813
830
|
if (target.getAttribute(imageMarker) !== preparedKeys.get(target)) {
|
|
@@ -823,7 +840,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
823
840
|
if (retiring) return;
|
|
824
841
|
}
|
|
825
842
|
dispose(previous);
|
|
826
|
-
if (
|
|
843
|
+
if (handoff) abandon(handoff.image, false);
|
|
827
844
|
else {
|
|
828
845
|
detach();
|
|
829
846
|
preparedKeys.clear();
|
|
@@ -837,13 +854,13 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
837
854
|
};
|
|
838
855
|
const end = () => {
|
|
839
856
|
locallyRequested = false;
|
|
840
|
-
if (!
|
|
857
|
+
if (!imageRequested()) restore();
|
|
841
858
|
};
|
|
842
859
|
const projectionChanged = () =>
|
|
843
860
|
queueMicrotask(() => {
|
|
844
861
|
if (stopped) return;
|
|
845
|
-
if (
|
|
846
|
-
else
|
|
862
|
+
if (imageRequested()) present();
|
|
863
|
+
else restore();
|
|
847
864
|
callbacks.wake();
|
|
848
865
|
});
|
|
849
866
|
let eventListener: Awaited<ReturnType<typeof bindProjectionEvents>> | undefined;
|
|
@@ -876,10 +893,10 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
876
893
|
const stopPause = projectionRequest.subscribe(projectionChanged);
|
|
877
894
|
return {
|
|
878
895
|
get owners(): readonly HTMLElement[] {
|
|
879
|
-
return [...
|
|
896
|
+
return handoff && handoff.phase !== 'restoring' ? [...handoff.image.sources] : [];
|
|
880
897
|
},
|
|
881
898
|
get visibleOwners(): readonly HTMLElement[] {
|
|
882
|
-
return [...
|
|
899
|
+
return handoff && (handoff.phase === 'image' || (handoff.phase === 'restoring' && handoff.visible)) ? [...handoff.image.sources] : [];
|
|
883
900
|
},
|
|
884
901
|
get preparedOwners(): readonly HTMLElement[] {
|
|
885
902
|
return [...preparedOwners];
|
|
@@ -888,11 +905,11 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP
|
|
|
888
905
|
changed,
|
|
889
906
|
moved: updateOffsets,
|
|
890
907
|
get token() {
|
|
891
|
-
return
|
|
908
|
+
return restoring() ? undefined : handoff?.image.data.token;
|
|
892
909
|
},
|
|
893
910
|
begin,
|
|
894
911
|
get restoring() {
|
|
895
|
-
return
|
|
912
|
+
return restoring();
|
|
896
913
|
},
|
|
897
914
|
get handoffPending() {
|
|
898
915
|
return handoffPending;
|
|
@@ -945,6 +962,7 @@ export const createWebProjectionBatch = async (
|
|
|
945
962
|
let latest: { revision: number; sources: Candidate[] } | undefined;
|
|
946
963
|
let stopped = false;
|
|
947
964
|
let batchDocument: Document | undefined;
|
|
965
|
+
let pendingRequest = false;
|
|
948
966
|
return {
|
|
949
967
|
get owners() {
|
|
950
968
|
return engine?.owners ?? [];
|
|
@@ -990,6 +1008,8 @@ export const createWebProjectionBatch = async (
|
|
|
990
1008
|
return;
|
|
991
1009
|
}
|
|
992
1010
|
engine = created;
|
|
1011
|
+
if (pendingRequest) engine.begin();
|
|
1012
|
+
pendingRequest = false;
|
|
993
1013
|
if (latest) engine.consider(latest.revision, latest.sources);
|
|
994
1014
|
})
|
|
995
1015
|
.catch(() => {
|
|
@@ -999,8 +1019,14 @@ export const createWebProjectionBatch = async (
|
|
|
999
1019
|
},
|
|
1000
1020
|
changed: (owner) => engine?.changed(owner),
|
|
1001
1021
|
moved: () => engine?.moved(),
|
|
1002
|
-
begin
|
|
1003
|
-
|
|
1022
|
+
begin() {
|
|
1023
|
+
if (engine) engine.begin();
|
|
1024
|
+
else pendingRequest = true;
|
|
1025
|
+
},
|
|
1026
|
+
end() {
|
|
1027
|
+
pendingRequest = false;
|
|
1028
|
+
engine?.end();
|
|
1029
|
+
},
|
|
1004
1030
|
updated: (token, ids, revision) => engine?.updated(token, ids, revision),
|
|
1005
1031
|
retireRestoration: () => engine?.retireRestoration(),
|
|
1006
1032
|
invalidate: (nativeHidden?: boolean) => engine?.invalidate(nativeHidden),
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface ScrollPlacement {
|
|
|
21
21
|
/** A source and its native rendering. Kind/placement are opaque layout identity keys. */
|
|
22
22
|
export interface WebProjectionSource {
|
|
23
23
|
element: HTMLElement;
|
|
24
|
+
/** Exact DOM sources represented by this control. Replaces the default [element] when supplied. */
|
|
24
25
|
sources?: HTMLElement[];
|
|
25
26
|
scrollElement?: HTMLElement;
|
|
26
27
|
control: Frame & { id: string; kind?: string; placement?: string; dark?: boolean; rtl?: boolean; scroll?: ScrollPlacement };
|