@webview-native-ui/engine 1.0.0 → 1.0.2-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -35
- package/WebViewNativeUIControls.podspec +17 -0
- package/dist/controls/activation.d.ts +15 -0
- package/dist/controls/activation.js +370 -0
- package/dist/controls/components/index.d.ts +16 -0
- package/dist/controls/components/index.js +38 -0
- package/dist/controls/components/ion-back-button.d.ts +4 -0
- package/dist/controls/components/ion-back-button.js +16 -0
- package/dist/controls/components/ion-button.d.ts +4 -0
- package/dist/controls/components/ion-button.js +26 -0
- package/dist/controls/components/ion-buttons.d.ts +5 -0
- package/dist/controls/components/ion-buttons.js +34 -0
- package/dist/controls/components/ion-fab.d.ts +4 -0
- package/dist/controls/components/ion-fab.js +164 -0
- package/dist/controls/components/ion-menu-button.d.ts +7 -0
- package/dist/controls/components/ion-menu-button.js +41 -0
- package/dist/controls/components/ion-segment.d.ts +5 -0
- package/dist/controls/components/ion-segment.js +24 -0
- package/dist/controls/components/ion-tab-bar.d.ts +5 -0
- package/dist/controls/components/ion-tab-bar.js +49 -0
- package/dist/controls/components/searchable-tabs.d.ts +20 -0
- package/dist/controls/components/searchable-tabs.js +525 -0
- package/dist/controls/definitions.d.ts +360 -0
- package/dist/controls/definitions.js +1 -0
- package/dist/controls/extensions.d.ts +44 -0
- package/dist/controls/extensions.js +164 -0
- package/dist/controls/foldable/fold-state.d.ts +4 -0
- package/dist/controls/foldable/fold-state.js +8 -0
- package/dist/controls/foldable/types.d.ts +9 -0
- package/dist/controls/foldable/types.js +1 -0
- package/dist/controls/html.d.ts +8 -0
- package/dist/controls/html.js +260 -0
- package/dist/controls/index.d.ts +7 -0
- package/dist/controls/index.js +4 -0
- package/dist/controls/ionic.d.ts +1 -0
- package/dist/controls/ionic.js +1 -0
- package/dist/controls/legacy-layout.d.ts +7 -0
- package/dist/controls/legacy-layout.js +23 -0
- package/dist/controls/overlays/content.d.ts +2 -0
- package/dist/controls/overlays/content.js +55 -0
- package/dist/controls/overlays/controller.d.ts +7 -0
- package/dist/controls/overlays/controller.js +369 -0
- package/dist/controls/overlays/covered-overlay.d.ts +4 -0
- package/dist/controls/overlays/covered-overlay.js +9 -0
- package/dist/controls/overlays/dialog-animation.d.ts +7 -0
- package/dist/controls/overlays/dialog-animation.js +49 -0
- package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
- package/dist/controls/overlays/dismiss-animation.js +14 -0
- package/dist/controls/overlays/focus.d.ts +2 -0
- package/dist/controls/overlays/focus.js +67 -0
- package/dist/controls/overlays/popover.d.ts +10 -0
- package/dist/controls/overlays/popover.js +117 -0
- package/dist/controls/overlays/styles.d.ts +9 -0
- package/dist/controls/overlays/styles.js +194 -0
- package/dist/controls/prehide.d.ts +6 -0
- package/dist/controls/prehide.js +280 -0
- package/dist/controls/runtime.d.ts +5 -0
- package/dist/controls/runtime.js +1685 -0
- package/dist/controls/shared/bridge.d.ts +4 -0
- package/dist/controls/shared/bridge.js +15 -0
- package/dist/controls/shared/candidate.d.ts +23 -0
- package/dist/controls/shared/candidate.js +128 -0
- package/dist/controls/shared/clipping.d.ts +1 -0
- package/dist/controls/shared/clipping.js +1 -0
- package/dist/controls/shared/connection.d.ts +21 -0
- package/dist/controls/shared/connection.js +112 -0
- package/dist/controls/shared/control-options.d.ts +4 -0
- package/dist/controls/shared/control-options.js +14 -0
- package/dist/controls/shared/covered-snapshot.d.ts +35 -0
- package/dist/controls/shared/covered-snapshot.js +41 -0
- package/dist/controls/shared/covered-source.d.ts +22 -0
- package/dist/controls/shared/covered-source.js +72 -0
- package/dist/controls/shared/crossfade.d.ts +11 -0
- package/dist/controls/shared/crossfade.js +71 -0
- package/dist/controls/shared/diagnostics.d.ts +2 -0
- package/dist/controls/shared/diagnostics.js +16 -0
- package/dist/controls/shared/dom.d.ts +45 -0
- package/dist/controls/shared/dom.js +258 -0
- package/dist/controls/shared/events.d.ts +12 -0
- package/dist/controls/shared/events.js +13 -0
- package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
- package/dist/controls/shared/horizontal-navigation.js +90 -0
- package/dist/controls/shared/icons.d.ts +5 -0
- package/dist/controls/shared/icons.js +66 -0
- package/dist/controls/shared/modal-animation.d.ts +21 -0
- package/dist/controls/shared/modal-animation.js +97 -0
- package/dist/controls/shared/modal.d.ts +26 -0
- package/dist/controls/shared/modal.js +261 -0
- package/dist/controls/shared/projection-id.d.ts +2 -0
- package/dist/controls/shared/projection-id.js +2 -0
- package/dist/controls/shared/scroll-placement.d.ts +1 -0
- package/dist/controls/shared/scroll-placement.js +1 -0
- package/dist/controls/shared/tab-bar-width.d.ts +7 -0
- package/dist/controls/shared/tab-bar-width.js +72 -0
- package/dist/controls/shared/toolbar-options.d.ts +10 -0
- package/dist/controls/shared/toolbar-options.js +38 -0
- package/dist/controls/shared/transition-identity.d.ts +3 -0
- package/dist/controls/shared/transition-identity.js +64 -0
- package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
- package/dist/controls/shared/vertical-bars-geometry.js +82 -0
- package/dist/controls/shared/vertical-navigation.d.ts +5 -0
- package/dist/controls/shared/vertical-navigation.js +39 -0
- package/dist/controls/startup.d.ts +7 -0
- package/dist/controls/startup.js +72 -0
- package/dist/controls/transport.d.ts +10 -0
- package/dist/controls/transport.js +1 -0
- package/dist/controls/vertical-bars-web-search.d.ts +8 -0
- package/dist/controls/vertical-bars-web-search.js +83 -0
- package/dist/controls/vertical-bars-web.d.ts +2 -0
- package/dist/controls/vertical-bars-web.js +609 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integration/index.d.ts +36 -0
- package/dist/integration/index.js +25 -0
- package/dist/native/bootstrap.js +1 -1
- package/dist/native/session.js +14 -8
- package/dist/projection/pause.d.ts +10 -0
- package/dist/projection/pause.js +58 -0
- package/dist/projection/styles.d.ts +1 -1
- package/dist/projection/styles.js +0 -1
- package/dist/projection/surfaces.js +11 -8
- package/dist/projection/web.js +48 -13
- package/dist/types.d.ts +5 -1
- package/docs/README.md +17 -10
- package/docs/concepts.md +39 -14
- package/docs/configuration/README.md +4 -2
- package/docs/configuration/ionic-overlays.md +36 -2
- package/docs/configuration/pause-resume.md +51 -0
- package/docs/controls.md +39 -0
- package/docs/getting-started/capacitor.md +3 -3
- package/docs/getting-started/cordova.md +3 -1
- package/docs/getting-started/initialization.md +18 -2
- package/docs/reference/platforms.md +2 -2
- package/docs/reference/projection.md +1 -1
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
- package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
- package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
- package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
- package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
- package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
- package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
- package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
- package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +6 -4
- package/ios/Sources/WebViewNativeUIEngine/ProjectionOrigins.swift +31 -0
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
- package/package.json +30 -5
- package/src/controls/activation.ts +427 -0
- package/src/controls/components/index.ts +45 -0
- package/src/controls/components/ion-back-button.ts +22 -0
- package/src/controls/components/ion-button.ts +28 -0
- package/src/controls/components/ion-buttons.ts +37 -0
- package/src/controls/components/ion-fab.ts +151 -0
- package/src/controls/components/ion-menu-button.ts +37 -0
- package/src/controls/components/ion-segment.ts +27 -0
- package/src/controls/components/ion-tab-bar.ts +55 -0
- package/src/controls/components/searchable-tabs.ts +534 -0
- package/src/controls/definitions.ts +338 -0
- package/src/controls/extensions.ts +198 -0
- package/src/controls/foldable/fold-state.ts +10 -0
- package/src/controls/foldable/types.ts +4 -0
- package/src/controls/html.ts +239 -0
- package/src/controls/index.ts +7 -0
- package/src/controls/ionic.ts +1 -0
- package/src/controls/legacy-layout.ts +21 -0
- package/src/controls/overlays/content.ts +51 -0
- package/src/controls/overlays/controller.ts +394 -0
- package/src/controls/overlays/covered-overlay.ts +11 -0
- package/src/controls/overlays/dialog-animation.ts +53 -0
- package/src/controls/overlays/dismiss-animation.ts +15 -0
- package/src/controls/overlays/focus.ts +64 -0
- package/src/controls/overlays/popover.ts +116 -0
- package/src/controls/overlays/styles.ts +195 -0
- package/src/controls/prehide.ts +285 -0
- package/src/controls/runtime.ts +1731 -0
- package/src/controls/shared/bridge.ts +18 -0
- package/src/controls/shared/candidate.ts +163 -0
- package/src/controls/shared/clipping.ts +1 -0
- package/src/controls/shared/connection.ts +105 -0
- package/src/controls/shared/control-options.ts +20 -0
- package/src/controls/shared/covered-snapshot.ts +52 -0
- package/src/controls/shared/covered-source.ts +78 -0
- package/src/controls/shared/crossfade.ts +70 -0
- package/src/controls/shared/diagnostics.ts +13 -0
- package/src/controls/shared/dom.ts +310 -0
- package/src/controls/shared/events.ts +14 -0
- package/src/controls/shared/horizontal-navigation.ts +106 -0
- package/src/controls/shared/icons.ts +64 -0
- package/src/controls/shared/modal-animation.ts +109 -0
- package/src/controls/shared/modal.ts +286 -0
- package/src/controls/shared/projection-id.ts +2 -0
- package/src/controls/shared/scroll-placement.ts +1 -0
- package/src/controls/shared/tab-bar-width.ts +66 -0
- package/src/controls/shared/toolbar-options.ts +51 -0
- package/src/controls/shared/transition-identity.ts +67 -0
- package/src/controls/shared/vertical-bars-geometry.ts +92 -0
- package/src/controls/shared/vertical-navigation.ts +34 -0
- package/src/controls/startup.ts +74 -0
- package/src/controls/transport.ts +11 -0
- package/src/controls/vertical-bars-web-search.ts +86 -0
- package/src/controls/vertical-bars-web.ts +674 -0
- package/src/dom/geometry.ts +24 -0
- package/src/dom/overlays.ts +196 -0
- package/src/dom/scroll-bindings.ts +66 -0
- package/src/dom/scroll-placement.ts +92 -0
- package/src/dom/visibility.ts +29 -0
- package/src/index.ts +19 -0
- package/src/integration/index.ts +64 -0
- package/src/ionic.ts +34 -0
- package/src/native/bootstrap.ts +132 -0
- package/src/native/bridge.ts +18 -0
- package/src/native/resource-url.ts +11 -0
- package/src/native/session.ts +708 -0
- package/src/native/updates.ts +103 -0
- package/src/native/values.ts +29 -0
- package/src/projection/events.ts +58 -0
- package/src/projection/pause.ts +58 -0
- package/src/projection/placement.ts +34 -0
- package/src/projection/scope.ts +13 -0
- package/src/projection/styles.ts +26 -0
- package/src/projection/surface.ts +88 -0
- package/src/projection/surfaces.ts +119 -0
- package/src/projection/web.ts +1013 -0
- package/src/types.ts +58 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
import { projectionURL } from '../native/resource-url.js';
|
|
2
|
+
import type {
|
|
3
|
+
WebProjectionSource,
|
|
4
|
+
ScopedWebProjectionSource as Candidate,
|
|
5
|
+
PreparedWebProjection as CoveredSnapshot,
|
|
6
|
+
Frame,
|
|
7
|
+
WebProjectionTransport,
|
|
8
|
+
} from '../types.js';
|
|
9
|
+
import type { WebProjectionScope } from './scope.js';
|
|
10
|
+
import { bounded, boundedListener } from '../native/bridge.js';
|
|
11
|
+
import { bindProjectionEvents, type ProjectionEvents, type ProjectionDestination } from './events.js';
|
|
12
|
+
import { snapshotStyles, snapshotShadowStyles } from './styles.js';
|
|
13
|
+
import { getProjectionPause } from './pause.js';
|
|
14
|
+
|
|
15
|
+
export interface WebProjectionOptions {
|
|
16
|
+
/** Existing plugin notifications, scoped to this presentation surface. */
|
|
17
|
+
events?: ProjectionEvents;
|
|
18
|
+
/** Debounce before preparing replacement pixels, in milliseconds. Defaults to 50; never delays handoff. */
|
|
19
|
+
captureDelayMs?: number;
|
|
20
|
+
/** Preserve an idle underlay while native disarms a possible early drag cover. */
|
|
21
|
+
retainPreparedUntilRelease?: boolean;
|
|
22
|
+
wake(): void;
|
|
23
|
+
blocked(): boolean;
|
|
24
|
+
revision?(): number;
|
|
25
|
+
/** Content version for native pixel reuse; layout revision remains independent. */
|
|
26
|
+
captureRevision?(): number;
|
|
27
|
+
/** Select this surface's sources, e.g. the underlying page or the active overlay. */
|
|
28
|
+
eligible?(source: Candidate): boolean;
|
|
29
|
+
/** Reject a cached image when its owning surface has changed. */
|
|
30
|
+
current?(sources: HTMLElement[]): boolean;
|
|
31
|
+
/** Temporary movement handoff: rebase return pixels to the current display DOM. */
|
|
32
|
+
transient?: boolean;
|
|
33
|
+
/** Opaque full-view pixels map to the host border box and may paint beneath native. */
|
|
34
|
+
opaqueUnderlay?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Prepared {
|
|
38
|
+
data: CoveredSnapshot;
|
|
39
|
+
images: HTMLImageElement[];
|
|
40
|
+
sources: HTMLElement[];
|
|
41
|
+
targets: HTMLElement[];
|
|
42
|
+
scopes: WebProjectionScope[];
|
|
43
|
+
parents: (Node | null)[];
|
|
44
|
+
frames: Frame[];
|
|
45
|
+
viewport: string;
|
|
46
|
+
revision: number;
|
|
47
|
+
layout: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const layout = ({ control: c }: Candidate, positionIndependent = false) => [
|
|
51
|
+
c.id,
|
|
52
|
+
c.kind,
|
|
53
|
+
c.placement,
|
|
54
|
+
positionIndependent ? 0 : c.x,
|
|
55
|
+
positionIndependent ? 0 : c.y,
|
|
56
|
+
c.width,
|
|
57
|
+
c.height,
|
|
58
|
+
c.dark,
|
|
59
|
+
c.rtl,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Images are prepared after accepted native updates, never on the overlay's critical path. */
|
|
63
|
+
const createProjectionEngine = async (scope: WebProjectionScope, transport: WebProjectionTransport, callbacks: WebProjectionOptions) => {
|
|
64
|
+
const captureDelayMs = callbacks.captureDelayMs ?? 50;
|
|
65
|
+
if (!Number.isFinite(captureDelayMs) || captureDelayMs < 0 || captureDelayMs > 2147483647)
|
|
66
|
+
throw new RangeError('captureDelayMs must be a finite, nonnegative timer interval');
|
|
67
|
+
let destination: ProjectionDestination = 'native';
|
|
68
|
+
const doc = scope.element.ownerDocument;
|
|
69
|
+
const projectionRequest = getProjectionPause(doc);
|
|
70
|
+
let locallyRequested = false;
|
|
71
|
+
const win = doc.defaultView!;
|
|
72
|
+
// Native tokens are local to a transport; never use them as global CSS identities.
|
|
73
|
+
const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
|
|
74
|
+
const validSource = (source: HTMLElement) => source.isConnected && source.ownerDocument === doc;
|
|
75
|
+
const validTarget = (target: HTMLElement | undefined): target is HTMLElement => !!target?.isConnected && target.ownerDocument === doc;
|
|
76
|
+
// Keep current, preparing and pending-release images leased; reuse idle DOM slots.
|
|
77
|
+
const imagePool = new Map<HTMLElement, HTMLImageElement[]>();
|
|
78
|
+
const leasedImages = new Set<HTMLImageElement>();
|
|
79
|
+
const retireImage = (image: HTMLImageElement) => {
|
|
80
|
+
leasedImages.delete(image);
|
|
81
|
+
image.style.setProperty('display', 'none', 'important');
|
|
82
|
+
image.removeAttribute('data-native-ui-projection-image');
|
|
83
|
+
image.removeAttribute('src');
|
|
84
|
+
pruneIdleImages();
|
|
85
|
+
};
|
|
86
|
+
const call = transport.send.bind(transport);
|
|
87
|
+
const imageMarker = 'data-native-ui-snapshot';
|
|
88
|
+
const sourcesChangedEvent = `${scope.namespace}:sources-change`;
|
|
89
|
+
const owners = new Set<HTMLElement>(),
|
|
90
|
+
visibleOwners = new Set<HTMLElement>(),
|
|
91
|
+
preparedOwners = new Set<HTMLElement>();
|
|
92
|
+
const scopeIDs = new WeakMap<WebProjectionScope, number>();
|
|
93
|
+
let scopeSerial = 0;
|
|
94
|
+
const signature = (candidates: Candidate[]) =>
|
|
95
|
+
JSON.stringify(
|
|
96
|
+
candidates.map((candidate) => {
|
|
97
|
+
if (!scopeIDs.has(candidate.scope)) scopeIDs.set(candidate.scope, ++scopeSerial);
|
|
98
|
+
const origin = callbacks.transient && !callbacks.opaqueUnderlay ? candidate.scope.element.getBoundingClientRect() : undefined;
|
|
99
|
+
const relative = origin
|
|
100
|
+
? { ...candidate, control: { ...candidate.control, x: candidate.control.x - origin.x, y: candidate.control.y - origin.y } }
|
|
101
|
+
: candidate;
|
|
102
|
+
return [scopeIDs.get(candidate.scope), layout(relative, !!callbacks.opaqueUnderlay)];
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const sheets = new Map<Document | ShadowRoot, HTMLStyleElement>();
|
|
106
|
+
const rootFor = (element: HTMLElement) => {
|
|
107
|
+
const root = element.getRootNode();
|
|
108
|
+
return root.nodeType === 11 && 'host' in root ? (root as ShadowRoot) : doc;
|
|
109
|
+
};
|
|
110
|
+
const sheetFor = (root: Document | ShadowRoot) => {
|
|
111
|
+
if (sheets.has(root)) return;
|
|
112
|
+
const style = doc.createElement('style');
|
|
113
|
+
style.setAttribute('data-native-ui-snapshot-base', '');
|
|
114
|
+
style.textContent = snapshotStyles + (root.nodeType === 11 ? snapshotShadowStyles : '');
|
|
115
|
+
(root.nodeType === 11 ? root : (root as Document).head).append(style);
|
|
116
|
+
sheets.set(root, style);
|
|
117
|
+
};
|
|
118
|
+
// Each shadow tree is a separate style scope. Keep these rules alongside the
|
|
119
|
+
// prepared image; only the marker changes during the handoff.
|
|
120
|
+
const finishVisibilityTransitions = (target: HTMLElement) => {
|
|
121
|
+
// A visibility transition can keep the old content painted over the image.
|
|
122
|
+
// Do not stop layout, transform or opacity transitions owned by the renderer.
|
|
123
|
+
const animations = [
|
|
124
|
+
...target.getAnimations({ subtree: true }),
|
|
125
|
+
...[...(shadowHosts.get(target) ?? [])].flatMap((host) => host.shadowRoot?.getAnimations() ?? []),
|
|
126
|
+
];
|
|
127
|
+
for (const animation of animations)
|
|
128
|
+
if ('transitionProperty' in animation && animation.transitionProperty === 'visibility') animation.cancel();
|
|
129
|
+
};
|
|
130
|
+
const shadowHosts = new Map<HTMLElement, Set<HTMLElement>>();
|
|
131
|
+
const syncShadowContent = (target: HTMLElement) => {
|
|
132
|
+
const hosts = shadowHosts.get(target) ?? new Set<HTMLElement>();
|
|
133
|
+
shadowHosts.set(target, hosts);
|
|
134
|
+
const hidden = target.hasAttribute('data-native-ui-snapshot-source') || target.hasAttribute('data-native-ui-snapshot-prepared');
|
|
135
|
+
const visit = (element: HTMLElement) => {
|
|
136
|
+
if (element.hasAttribute('data-native-ui-projection-node')) return;
|
|
137
|
+
if (element.shadowRoot) {
|
|
138
|
+
hosts.add(element);
|
|
139
|
+
sheetFor(element.shadowRoot);
|
|
140
|
+
for (const child of element.shadowRoot.children) visit(child as HTMLElement);
|
|
141
|
+
}
|
|
142
|
+
for (const child of element.children) visit(child as HTMLElement);
|
|
143
|
+
};
|
|
144
|
+
visit(target);
|
|
145
|
+
for (const host of hosts) host.toggleAttribute('data-native-ui-snapshot-content', hidden);
|
|
146
|
+
finishVisibilityTransitions(target);
|
|
147
|
+
};
|
|
148
|
+
const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
|
|
149
|
+
let handoffPending = false;
|
|
150
|
+
let presented = false;
|
|
151
|
+
let paintFrame = 0;
|
|
152
|
+
let presentation = 0;
|
|
153
|
+
let cached: Prepared | undefined;
|
|
154
|
+
let retiring: object | undefined;
|
|
155
|
+
let active: Prepared | undefined;
|
|
156
|
+
let touching = false;
|
|
157
|
+
let ending = false;
|
|
158
|
+
let restoreTimeout = 0;
|
|
159
|
+
let restoreRevision = 0;
|
|
160
|
+
let generation = 0;
|
|
161
|
+
let timer = 0;
|
|
162
|
+
let visualRevision = 0;
|
|
163
|
+
let preparedVisualRevision = -1;
|
|
164
|
+
let stopped = false;
|
|
165
|
+
let flight = false;
|
|
166
|
+
let latest: { revision: number; candidates: Candidate[] } | undefined;
|
|
167
|
+
const pruneIdleImages = () => {
|
|
168
|
+
const targets = new Set(latest?.candidates.map((candidate) => candidate.scope.element));
|
|
169
|
+
for (const [target, pool] of imagePool) {
|
|
170
|
+
const reusable = !stopped && target.isConnected && targets.has(target);
|
|
171
|
+
if (reusable && (active || ending || handoffPending)) continue;
|
|
172
|
+
const limit = reusable ? 2 * Math.max(1, cached?.scopes.filter((scope) => scope.element === target).length ?? 0) : 0;
|
|
173
|
+
let spare = Math.max(0, limit - pool.filter((image) => leasedImages.has(image)).length);
|
|
174
|
+
const retained = pool.filter((image) => {
|
|
175
|
+
if (leasedImages.has(image) || spare-- > 0) return true;
|
|
176
|
+
image.remove();
|
|
177
|
+
return false;
|
|
178
|
+
});
|
|
179
|
+
if (retained.length) imagePool.set(target, retained);
|
|
180
|
+
else imagePool.delete(target);
|
|
181
|
+
}
|
|
182
|
+
const liveRoots = new Set<Node>([
|
|
183
|
+
...(latest?.candidates.map((candidate) => candidate.scope.element.getRootNode()) ?? []),
|
|
184
|
+
...(active?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
|
|
185
|
+
...(cached?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
|
|
186
|
+
...[...leasedImages].map((image) => image.getRootNode()),
|
|
187
|
+
...[...shadowHosts.values()].flatMap((hosts) => [...hosts].map((host) => host.shadowRoot!)),
|
|
188
|
+
]);
|
|
189
|
+
for (const [root, pair] of sheets)
|
|
190
|
+
if (root.nodeType === 11 && !liveRoots.has(root)) {
|
|
191
|
+
pair.remove();
|
|
192
|
+
sheets.delete(root);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const pendingCleanup = new Set<() => void>();
|
|
196
|
+
const cleanup = (action: 'release' | 'restored', token: string, afterPaint: boolean) => {
|
|
197
|
+
const run = () => {
|
|
198
|
+
if (!pendingCleanup.delete(run)) return;
|
|
199
|
+
void call({ action, token }).catch(() => {});
|
|
200
|
+
if (action === 'restored') pruneIdleImages();
|
|
201
|
+
};
|
|
202
|
+
pendingCleanup.add(run);
|
|
203
|
+
if (afterPaint && !stopped) win.requestAnimationFrame(() => win.requestAnimationFrame(run));
|
|
204
|
+
else run();
|
|
205
|
+
};
|
|
206
|
+
const preparedSources = new Set<HTMLElement>();
|
|
207
|
+
const clearPrepared = () => {
|
|
208
|
+
preparedSources.forEach((source) => preparedOwners.delete(source));
|
|
209
|
+
preparedSources.clear();
|
|
210
|
+
};
|
|
211
|
+
const dispose = (item: Prepared | undefined, afterPaint = false) => {
|
|
212
|
+
if (!item || item === cached || item === active) return;
|
|
213
|
+
item.images.forEach((image) => {
|
|
214
|
+
retireImage(image);
|
|
215
|
+
});
|
|
216
|
+
cleanup('release', item.data.token, afterPaint);
|
|
217
|
+
};
|
|
218
|
+
const select = (candidates: Candidate[]) =>
|
|
219
|
+
candidates.filter((candidate) => {
|
|
220
|
+
if (!validTarget(candidate.scope.element) || ![candidate.element, ...(candidate.sources ?? [])].every(validSource)) return false;
|
|
221
|
+
// Native scroll bindings retain offscreen controls. They have no visible
|
|
222
|
+
// pixels to capture and must not prevent the other controls being prepared.
|
|
223
|
+
if (candidate.control.scroll) {
|
|
224
|
+
const rect = candidate.element.getBoundingClientRect();
|
|
225
|
+
const clip = candidate.control.scroll.clip;
|
|
226
|
+
if (rect.right <= clip.x || rect.bottom <= clip.y || rect.left >= clip.x + clip.width || rect.top >= clip.y + clip.height)
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return callbacks.eligible?.(candidate) ?? true;
|
|
230
|
+
});
|
|
231
|
+
const prepare = async () => {
|
|
232
|
+
timer = 0;
|
|
233
|
+
if (
|
|
234
|
+
stopped ||
|
|
235
|
+
destination !== 'native' ||
|
|
236
|
+
flight ||
|
|
237
|
+
touching ||
|
|
238
|
+
active ||
|
|
239
|
+
callbacks.blocked() ||
|
|
240
|
+
!latest ||
|
|
241
|
+
(cached?.revision === latest.revision && preparedVisualRevision === visualRevision)
|
|
242
|
+
)
|
|
243
|
+
return;
|
|
244
|
+
const { revision, candidates } = latest;
|
|
245
|
+
const stamp = generation;
|
|
246
|
+
const visual = visualRevision;
|
|
247
|
+
const size = viewport();
|
|
248
|
+
const selected = select(candidates);
|
|
249
|
+
if (!selected.length) return;
|
|
250
|
+
flight = true;
|
|
251
|
+
let result: Partial<CoveredSnapshot> | undefined;
|
|
252
|
+
let adopted = false;
|
|
253
|
+
const images: HTMLImageElement[] = [];
|
|
254
|
+
try {
|
|
255
|
+
let expired = false;
|
|
256
|
+
const capture = call({
|
|
257
|
+
action: 'prepare',
|
|
258
|
+
revision: callbacks.captureRevision?.() ?? revision,
|
|
259
|
+
viewportWidth: win.innerWidth,
|
|
260
|
+
ids: selected.map((item) => item.control.id),
|
|
261
|
+
});
|
|
262
|
+
void capture
|
|
263
|
+
.then((late) => {
|
|
264
|
+
if (expired && late.token) void call({ action: 'release', token: late.token }).catch(() => {});
|
|
265
|
+
})
|
|
266
|
+
.catch(() => {});
|
|
267
|
+
try {
|
|
268
|
+
result = await bounded(capture);
|
|
269
|
+
} finally {
|
|
270
|
+
expired = true;
|
|
271
|
+
}
|
|
272
|
+
if (!result.token || !result.parts?.length) return;
|
|
273
|
+
const ids = new Set(selected.map((candidate) => candidate.control.id));
|
|
274
|
+
if (
|
|
275
|
+
result.parts.some(
|
|
276
|
+
(part) =>
|
|
277
|
+
(!part.url.startsWith('file:') &&
|
|
278
|
+
!/^\/__native_ui_engine__\/nativeUI[a-fA-F0-9]+\/[a-fA-F0-9-]+-[0-9]+\.png$/.test(part.url)) ||
|
|
279
|
+
!part.ids.length ||
|
|
280
|
+
part.ids.some((id) => !ids.has(id)) ||
|
|
281
|
+
![part.x, part.y, part.width, part.height].every(Number.isFinite) ||
|
|
282
|
+
part.width <= 0 ||
|
|
283
|
+
part.height <= 0,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return;
|
|
287
|
+
if (new Set(result.parts.flatMap((part) => part.ids)).size !== ids.size) return;
|
|
288
|
+
// One crop may cover a button group, but must have one fixed fallback destination.
|
|
289
|
+
if (
|
|
290
|
+
result.parts.some(
|
|
291
|
+
(part) => new Set(part.ids.map((id) => selected.find((candidate) => candidate.control.id === id)!.scope)).size !== 1,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
return;
|
|
295
|
+
const partSources = result.parts.map((part) => selected.find((candidate) => part.ids.includes(candidate.control.id))!);
|
|
296
|
+
await bounded(
|
|
297
|
+
Promise.all(
|
|
298
|
+
result.parts.map(async (part, index) => {
|
|
299
|
+
const target = partSources[index].scope.element;
|
|
300
|
+
let pool = imagePool.get(target);
|
|
301
|
+
if (!pool) {
|
|
302
|
+
pool = [];
|
|
303
|
+
imagePool.set(target, pool);
|
|
304
|
+
}
|
|
305
|
+
let image = pool.find((item) => !leasedImages.has(item));
|
|
306
|
+
if (!image) {
|
|
307
|
+
image = doc.createElement('img');
|
|
308
|
+
image.setAttribute('data-native-ui-projection-node', '');
|
|
309
|
+
pool.push(image);
|
|
310
|
+
}
|
|
311
|
+
leasedImages.add(image);
|
|
312
|
+
images.push(image);
|
|
313
|
+
image.src = projectionURL(part.url);
|
|
314
|
+
await image.decode();
|
|
315
|
+
return image;
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
if (
|
|
320
|
+
stopped ||
|
|
321
|
+
destination !== 'native' ||
|
|
322
|
+
touching ||
|
|
323
|
+
active ||
|
|
324
|
+
callbacks.blocked() ||
|
|
325
|
+
generation !== stamp ||
|
|
326
|
+
viewport() !== size ||
|
|
327
|
+
latest?.revision !== revision ||
|
|
328
|
+
selected.some(
|
|
329
|
+
(candidate) => !validTarget(candidate.scope.element) || ![candidate.element, ...(candidate.sources ?? [])].every(validSource),
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return;
|
|
333
|
+
const previous = cached;
|
|
334
|
+
const prepared: Prepared = {
|
|
335
|
+
data: result as CoveredSnapshot,
|
|
336
|
+
images,
|
|
337
|
+
sources: Array.from(new Set(selected.flatMap((item) => [item.element, ...(item.sources ?? [])]))),
|
|
338
|
+
targets: partSources.map((source) => source.element),
|
|
339
|
+
scopes: partSources.map((source) => source.scope),
|
|
340
|
+
parents: partSources.map((source) => source.scope.element.parentNode),
|
|
341
|
+
frames: [],
|
|
342
|
+
viewport: size,
|
|
343
|
+
revision,
|
|
344
|
+
layout: signature(selected),
|
|
345
|
+
};
|
|
346
|
+
try {
|
|
347
|
+
if (!render(prepared)) throw new Error('Projection destination is unavailable');
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// Do not publish a token whose DOM could not be installed. Restore the
|
|
350
|
+
// last completed presentation before retiring the failed preparation.
|
|
351
|
+
detach();
|
|
352
|
+
try {
|
|
353
|
+
render();
|
|
354
|
+
cached?.sources.forEach((source) => {
|
|
355
|
+
preparedOwners.add(source);
|
|
356
|
+
preparedSources.add(source);
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
abandon();
|
|
360
|
+
}
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
cached = prepared;
|
|
364
|
+
clearPrepared();
|
|
365
|
+
cached.sources.forEach((source) => {
|
|
366
|
+
preparedOwners.add(source);
|
|
367
|
+
preparedSources.add(source);
|
|
368
|
+
});
|
|
369
|
+
if (callbacks.opaqueUnderlay) void call({ action: 'prepared', token: result.token }).catch(() => {});
|
|
370
|
+
win.dispatchEvent(new Event(sourcesChangedEvent));
|
|
371
|
+
preparedVisualRevision = visual;
|
|
372
|
+
adopted = true;
|
|
373
|
+
dispose(previous);
|
|
374
|
+
if (projectionRequest.isPaused()) present();
|
|
375
|
+
} catch {
|
|
376
|
+
// Preparing a visual is optional. Preserve the previous image or the Web fallback.
|
|
377
|
+
} finally {
|
|
378
|
+
if (!adopted)
|
|
379
|
+
images.forEach((image) => {
|
|
380
|
+
retireImage(image);
|
|
381
|
+
});
|
|
382
|
+
if (result?.token && !adopted) void call({ action: 'release', token: result.token }).catch(() => {});
|
|
383
|
+
flight = false;
|
|
384
|
+
if (latest && (latest.revision !== revision || visualRevision !== visual)) schedule();
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
// Coalesce changes outside the presentation path. Completed images remain
|
|
388
|
+
// available while their replacement is being rendered and decoded.
|
|
389
|
+
const schedule = () => {
|
|
390
|
+
win.clearTimeout(timer);
|
|
391
|
+
if (!stopped && destination === 'native' && !active && !touching && !callbacks.blocked())
|
|
392
|
+
timer = win.setTimeout(() => void prepare(), captureDelayMs);
|
|
393
|
+
};
|
|
394
|
+
let consideredLayout: string | undefined;
|
|
395
|
+
const consider = (revision: number, candidates: Candidate[]) => {
|
|
396
|
+
if (
|
|
397
|
+
candidates.some(
|
|
398
|
+
(candidate) =>
|
|
399
|
+
candidate.scope.element.ownerDocument !== doc ||
|
|
400
|
+
[candidate.element, ...(candidate.sources ?? [])].some((element) => element.ownerDocument !== doc),
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
throw new TypeError('All projection sources and fallback scopes must share one Document');
|
|
404
|
+
// Overlay retirement is not a new source layout. Preserve the pre-overlay
|
|
405
|
+
// image until the presentation path has chosen whether to use it.
|
|
406
|
+
if (callbacks.blocked()) return;
|
|
407
|
+
if (!active && cached && cached.layout !== signature(select(candidates))) invalidate();
|
|
408
|
+
const nextLayout = signature(candidates);
|
|
409
|
+
if (consideredLayout !== nextLayout) {
|
|
410
|
+
consideredLayout = nextLayout;
|
|
411
|
+
visualRevision++;
|
|
412
|
+
generation++;
|
|
413
|
+
}
|
|
414
|
+
if (latest?.revision !== revision) generation++;
|
|
415
|
+
latest = { revision, candidates };
|
|
416
|
+
pruneIdleImages();
|
|
417
|
+
if (cached?.revision !== revision || preparedVisualRevision !== visualRevision) schedule();
|
|
418
|
+
};
|
|
419
|
+
const changed = (scrollOwner?: EventTarget) => {
|
|
420
|
+
// A pre-interaction state image is reusable; an image from another scroll
|
|
421
|
+
// position/clip is not. Invalidate only the surface containing that owner.
|
|
422
|
+
if (
|
|
423
|
+
!active &&
|
|
424
|
+
cached &&
|
|
425
|
+
scrollOwner &&
|
|
426
|
+
latest?.candidates.some((candidate) => candidate.scrollElement === scrollOwner && cached!.sources.includes(candidate.element))
|
|
427
|
+
)
|
|
428
|
+
invalidate();
|
|
429
|
+
if (active) {
|
|
430
|
+
updateOffsets();
|
|
431
|
+
}
|
|
432
|
+
visualRevision++;
|
|
433
|
+
generation++;
|
|
434
|
+
schedule();
|
|
435
|
+
};
|
|
436
|
+
const listener = await boundedListener(
|
|
437
|
+
transport.listen(({ active: pressed }) => {
|
|
438
|
+
touching = pressed;
|
|
439
|
+
generation++;
|
|
440
|
+
win.clearTimeout(timer);
|
|
441
|
+
// The completed cache itself is the pre-interaction image. No separate
|
|
442
|
+
// pin can outlive the interaction and override a newer completed image.
|
|
443
|
+
if (!pressed) schedule();
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
const preparedTargets = new Set<HTMLElement>();
|
|
447
|
+
const clearTarget = (target: HTMLElement) => {
|
|
448
|
+
shadowHosts.get(target)?.forEach((host) => host.removeAttribute('data-native-ui-snapshot-content'));
|
|
449
|
+
if (preparedTargets.delete(target)) target.removeAttribute('data-native-ui-snapshot-prepared');
|
|
450
|
+
target.removeAttribute('data-native-ui-snapshot-visible');
|
|
451
|
+
target.removeAttribute(imageMarker);
|
|
452
|
+
target.removeAttribute('data-native-ui-snapshot-positioned');
|
|
453
|
+
target.removeAttribute('data-native-ui-snapshot-source');
|
|
454
|
+
finishVisibilityTransitions(target);
|
|
455
|
+
shadowHosts.delete(target);
|
|
456
|
+
};
|
|
457
|
+
const detach = () => {
|
|
458
|
+
presentation++;
|
|
459
|
+
win.cancelAnimationFrame(paintFrame);
|
|
460
|
+
for (const element of offsets.keys()) element.style.setProperty('opacity', '0', 'important');
|
|
461
|
+
offsets.clear();
|
|
462
|
+
paintFrame = 0;
|
|
463
|
+
handoffPending = false;
|
|
464
|
+
presented = false;
|
|
465
|
+
clearPrepared();
|
|
466
|
+
active?.sources.forEach((source) => {
|
|
467
|
+
owners.delete(source);
|
|
468
|
+
visibleOwners.delete(source);
|
|
469
|
+
});
|
|
470
|
+
rendered.forEach(clearTarget);
|
|
471
|
+
rendered.clear();
|
|
472
|
+
sheets.forEach((style, root) => {
|
|
473
|
+
if (stopped || (root.nodeType === 11 && !(root as ShadowRoot).host.isConnected)) {
|
|
474
|
+
style.remove();
|
|
475
|
+
sheets.delete(root);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
const abandon = (expected = active, afterPaint = ending) => {
|
|
480
|
+
if (active !== expected) return;
|
|
481
|
+
const previous = new Set([active, cached]);
|
|
482
|
+
detach();
|
|
483
|
+
active = undefined;
|
|
484
|
+
cached = undefined;
|
|
485
|
+
ending = false;
|
|
486
|
+
win.clearTimeout(restoreTimeout);
|
|
487
|
+
previous.forEach((item) => dispose(item, afterPaint));
|
|
488
|
+
callbacks.wake();
|
|
489
|
+
};
|
|
490
|
+
const finishRestoration = () => {
|
|
491
|
+
if (!active) return;
|
|
492
|
+
// A moved return invalidates the cached coordinates, but keeps the active
|
|
493
|
+
// visual until Native has taken ownership at its destination.
|
|
494
|
+
if (cached !== active) {
|
|
495
|
+
abandon();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const image = active;
|
|
499
|
+
presentation++;
|
|
500
|
+
win.cancelAnimationFrame(paintFrame);
|
|
501
|
+
paintFrame = 0;
|
|
502
|
+
handoffPending = presented = false;
|
|
503
|
+
image.sources.forEach((source) => {
|
|
504
|
+
owners.delete(source);
|
|
505
|
+
visibleOwners.delete(source);
|
|
506
|
+
});
|
|
507
|
+
rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source'));
|
|
508
|
+
showImages(image, !!callbacks.opaqueUnderlay);
|
|
509
|
+
active = undefined;
|
|
510
|
+
ending = false;
|
|
511
|
+
win.clearTimeout(restoreTimeout);
|
|
512
|
+
// Retire only the temporary return copy. Keep the pre-interaction image
|
|
513
|
+
// available until a replacement finishes, including rapid overlay reopening.
|
|
514
|
+
// Native's update acknowledgement precedes WebKit's paint. Keep its return
|
|
515
|
+
// copy over live glass until the removed Web image has crossed a paint boundary.
|
|
516
|
+
// Flush this callback before another cover can reuse the same token.
|
|
517
|
+
cleanup('restored', image.data.token, true);
|
|
518
|
+
image.sources.forEach((source) => {
|
|
519
|
+
preparedOwners.add(source);
|
|
520
|
+
preparedSources.add(source);
|
|
521
|
+
});
|
|
522
|
+
callbacks.wake();
|
|
523
|
+
};
|
|
524
|
+
const retireRestoration = () => {
|
|
525
|
+
if (ending) finishRestoration();
|
|
526
|
+
pendingCleanup.forEach((run) => run());
|
|
527
|
+
};
|
|
528
|
+
const targetsFor = (image: Prepared) => image.scopes.map((scope) => (validTarget(scope.element) ? scope.element : undefined));
|
|
529
|
+
const rendered = new Set<HTMLElement>();
|
|
530
|
+
const showImages = (image: Prepared, visible: boolean) => {
|
|
531
|
+
for (const target of rendered) {
|
|
532
|
+
target.toggleAttribute('data-native-ui-snapshot-visible', visible);
|
|
533
|
+
syncShadowContent(target);
|
|
534
|
+
}
|
|
535
|
+
image.images.forEach((element) => element.style.setProperty('opacity', visible ? '1' : '0', 'important'));
|
|
536
|
+
};
|
|
537
|
+
const preparedKeys = new Map<HTMLElement, string>();
|
|
538
|
+
const offsets = new Map<HTMLImageElement, { target: HTMLElement; frame: Frame; scale?: { x: number; y: number } }>();
|
|
539
|
+
const updateOffsets = () => {
|
|
540
|
+
const writes: { element: HTMLImageElement; left: number; top: number; width: number; height: number }[] = [];
|
|
541
|
+
// Read all geometry before writing positions; never reset a painted image to zero to measure it.
|
|
542
|
+
for (const [element, placement] of offsets) {
|
|
543
|
+
const { target, frame } = placement;
|
|
544
|
+
if (!target.isConnected) {
|
|
545
|
+
element.remove();
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const parent = target.shadowRoot ?? target;
|
|
549
|
+
if (element.parentNode !== parent) parent.insertBefore(element, null);
|
|
550
|
+
const box = target.getBoundingClientRect();
|
|
551
|
+
const rect = element.getBoundingClientRect();
|
|
552
|
+
const width = parseFloat(element.style.width) || frame.width;
|
|
553
|
+
const height = parseFloat(element.style.height) || frame.height;
|
|
554
|
+
const sx = rect.width / width || 1,
|
|
555
|
+
sy = rect.height / height || 1;
|
|
556
|
+
const initial = placement.scale ?? (placement.scale = { x: sx, y: sy });
|
|
557
|
+
writes.push({
|
|
558
|
+
element,
|
|
559
|
+
left: (parseFloat(element.style.left) || 0) + (box.x + (frame.x * sx) / initial.x - rect.x) / sx,
|
|
560
|
+
top: (parseFloat(element.style.top) || 0) + (box.y + (frame.y * sy) / initial.y - rect.y) / sy,
|
|
561
|
+
width: frame.width / initial.x,
|
|
562
|
+
height: frame.height / initial.y,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
for (const { element, ...values } of writes)
|
|
566
|
+
for (const [property, value] of Object.entries(values)) {
|
|
567
|
+
const next = `${value}px`;
|
|
568
|
+
if (element.style.getPropertyValue(property) !== next) element.style.setProperty(property, next, 'important');
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const render = (image = active ?? cached) => {
|
|
572
|
+
if (!image || ending) return false;
|
|
573
|
+
if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
|
|
574
|
+
invalidate();
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
const targets = targetsFor(image);
|
|
578
|
+
if (active) {
|
|
579
|
+
if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
|
|
580
|
+
abandon(active, false);
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
const next = new Set<HTMLElement>();
|
|
584
|
+
for (const [index, source] of image.targets.entries()) {
|
|
585
|
+
const key = preparedKeys.get(source);
|
|
586
|
+
const target = targets[index];
|
|
587
|
+
if (!key || !target?.isConnected) continue;
|
|
588
|
+
target.setAttribute(imageMarker, key);
|
|
589
|
+
target.setAttribute('data-native-ui-snapshot-source', '');
|
|
590
|
+
next.add(target);
|
|
591
|
+
}
|
|
592
|
+
for (const target of rendered) if (!next.has(target)) clearTarget(target);
|
|
593
|
+
rendered.clear();
|
|
594
|
+
next.forEach((target) => rendered.add(target));
|
|
595
|
+
showImages(image, presented);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
imagePool.forEach((pool) =>
|
|
599
|
+
pool.forEach((element) => {
|
|
600
|
+
if (!image.images.includes(element)) {
|
|
601
|
+
element.style.setProperty('display', 'none', 'important');
|
|
602
|
+
element.removeAttribute('data-native-ui-projection-image');
|
|
603
|
+
}
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
preparedKeys.clear();
|
|
607
|
+
offsets.clear();
|
|
608
|
+
rendered.forEach(clearTarget);
|
|
609
|
+
rendered.clear();
|
|
610
|
+
for (const target of new Set(targets)) {
|
|
611
|
+
if (!target) continue;
|
|
612
|
+
rendered.add(target);
|
|
613
|
+
sheetFor(rootFor(target));
|
|
614
|
+
const indices = targets.flatMap((owner, index) => (owner === target ? [index] : []));
|
|
615
|
+
const key = `${imageIdentity}-${indices[0]}`;
|
|
616
|
+
target.setAttribute(imageMarker, key);
|
|
617
|
+
if (target !== image.targets[indices[0]]) {
|
|
618
|
+
target.setAttribute('data-native-ui-snapshot-prepared', '');
|
|
619
|
+
preparedTargets.add(target);
|
|
620
|
+
}
|
|
621
|
+
target.toggleAttribute('data-native-ui-snapshot-visible', !!callbacks.opaqueUnderlay);
|
|
622
|
+
syncShadowContent(target);
|
|
623
|
+
const box = target.getBoundingClientRect();
|
|
624
|
+
target.toggleAttribute('data-native-ui-snapshot-positioned', win.getComputedStyle(target).position === 'static');
|
|
625
|
+
const parent = target.shadowRoot ?? target;
|
|
626
|
+
for (const index of [...indices].reverse()) {
|
|
627
|
+
const element = image.images[index];
|
|
628
|
+
const part = image.data.parts[index];
|
|
629
|
+
const frame = image.frames[index] ?? {
|
|
630
|
+
x: callbacks.opaqueUnderlay ? 0 : part.x - box.x,
|
|
631
|
+
y: callbacks.opaqueUnderlay ? 0 : part.y - box.y,
|
|
632
|
+
width: part.width,
|
|
633
|
+
height: part.height,
|
|
634
|
+
};
|
|
635
|
+
image.frames[index] = frame;
|
|
636
|
+
preparedKeys.set(image.targets[index], key);
|
|
637
|
+
element.inert = true;
|
|
638
|
+
element.setAttribute('aria-hidden', 'true');
|
|
639
|
+
element.setAttribute('data-native-ui-projection-image', key);
|
|
640
|
+
element.style.cssText =
|
|
641
|
+
'all:initial!important;position:absolute!important;display:block!important;visibility:visible!important;pointer-events:none!important;image-rendering:pixelated!important;will-change:opacity!important;opacity:0!important;';
|
|
642
|
+
element.style.setProperty('width', `${frame.width}px`, 'important');
|
|
643
|
+
element.style.setProperty('height', `${frame.height}px`, 'important');
|
|
644
|
+
element.style.setProperty('left', '0px', 'important');
|
|
645
|
+
element.style.setProperty('top', '0px', 'important');
|
|
646
|
+
element.style.setProperty('opacity', callbacks.opaqueUnderlay ? '1' : '0', 'important');
|
|
647
|
+
if (element.parentNode !== parent) parent.insertBefore(element, null);
|
|
648
|
+
offsets.set(element, { target, frame });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
image.parents = image.scopes.map((scope) => scope.element.parentNode);
|
|
652
|
+
updateOffsets();
|
|
653
|
+
return true;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const currentParts = (image: Prepared) =>
|
|
657
|
+
image.images.map((element, index) => {
|
|
658
|
+
const rect = element.getBoundingClientRect();
|
|
659
|
+
return { ...image.data.parts[index], x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const present = (force = false) => {
|
|
663
|
+
retireRestoration();
|
|
664
|
+
if (stopped || destination === 'none' || (active && !force)) return;
|
|
665
|
+
const image = active ?? cached;
|
|
666
|
+
if (
|
|
667
|
+
!image ||
|
|
668
|
+
image.viewport !== viewport() ||
|
|
669
|
+
!image.sources.every(validSource) ||
|
|
670
|
+
(callbacks.current && !callbacks.current(image.targets)) ||
|
|
671
|
+
image.targets.some((source) => !preparedKeys.has(source)) ||
|
|
672
|
+
targetsFor(image).some((target) => !target?.isConnected) ||
|
|
673
|
+
[...rendered].some((target) => !target.isConnected)
|
|
674
|
+
)
|
|
675
|
+
return;
|
|
676
|
+
// Reparenting is renderer-owned. Only that case needs a fresh coordinate conversion;
|
|
677
|
+
// ordinary presentation keeps the prepared layout off the critical path.
|
|
678
|
+
if (image.scopes.some((scope, index) => scope.element.parentNode !== image.parents[index])) render();
|
|
679
|
+
updateOffsets();
|
|
680
|
+
// A cached image may span multiple presentations; bridge replies may not.
|
|
681
|
+
const currentPresentation = ++presentation;
|
|
682
|
+
const isCurrent = () => presentation === currentPresentation;
|
|
683
|
+
handoffPending = true;
|
|
684
|
+
presented = !!callbacks.opaqueUnderlay;
|
|
685
|
+
active = image;
|
|
686
|
+
// Reuse decoded pixels; no capture, bridge acknowledgement or animation wait.
|
|
687
|
+
generation++;
|
|
688
|
+
win.clearTimeout(timer);
|
|
689
|
+
image.sources.forEach((source) => {
|
|
690
|
+
owners.add(source);
|
|
691
|
+
});
|
|
692
|
+
rendered.forEach((target) => target.setAttribute('data-native-ui-snapshot-source', ''));
|
|
693
|
+
showImages(image, presented);
|
|
694
|
+
// Reconciliation may continue after one frame even if the optional bridge reply
|
|
695
|
+
// is delayed. The ordered native transport processes cover before that update.
|
|
696
|
+
paintFrame = win.requestAnimationFrame(() => {
|
|
697
|
+
paintFrame = 0;
|
|
698
|
+
if (!isCurrent()) return;
|
|
699
|
+
handoffPending = false;
|
|
700
|
+
callbacks.wake();
|
|
701
|
+
});
|
|
702
|
+
void bounded(
|
|
703
|
+
call({
|
|
704
|
+
action: 'cover',
|
|
705
|
+
token: image.data.token,
|
|
706
|
+
...(callbacks.transient ? { transient: true, viewportWidth: win.innerWidth, parts: currentParts(image) } : {}),
|
|
707
|
+
}),
|
|
708
|
+
)
|
|
709
|
+
.then((result) => {
|
|
710
|
+
if (!isCurrent()) return;
|
|
711
|
+
handoffPending = false;
|
|
712
|
+
if (result?.shown !== true) {
|
|
713
|
+
abandon(image, false);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
// Live translucent Native must be hidden before its composited Web image
|
|
717
|
+
// becomes visible. Native's prepared entry copy spans this bridge interval.
|
|
718
|
+
if (!ending) {
|
|
719
|
+
presented = true;
|
|
720
|
+
image.sources.forEach((source) => visibleOwners.add(source));
|
|
721
|
+
render();
|
|
722
|
+
updateOffsets();
|
|
723
|
+
}
|
|
724
|
+
win.cancelAnimationFrame(paintFrame);
|
|
725
|
+
paintFrame = win.requestAnimationFrame(() => {
|
|
726
|
+
paintFrame = 0;
|
|
727
|
+
if (!isCurrent()) return;
|
|
728
|
+
void call({ action: 'painted', token: image.data.token })
|
|
729
|
+
.then((result) => {
|
|
730
|
+
if (isCurrent() && result?.shown === false) abandon(image, false);
|
|
731
|
+
})
|
|
732
|
+
.catch(() => {
|
|
733
|
+
if (isCurrent()) abandon(image, false);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
callbacks.wake();
|
|
737
|
+
})
|
|
738
|
+
.catch(() => {
|
|
739
|
+
if (isCurrent()) abandon(image, false);
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
const restore = () => {
|
|
743
|
+
if (!active || stopped || ending) return;
|
|
744
|
+
// A return belongs to the captured surface, never to the modal exposed beneath it.
|
|
745
|
+
if (
|
|
746
|
+
!active.sources.every(validSource) ||
|
|
747
|
+
targetsFor(active).some((target) => !target) ||
|
|
748
|
+
(callbacks.current && !callbacks.current(active.targets))
|
|
749
|
+
) {
|
|
750
|
+
abandon(active, false);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (callbacks.blocked()) return;
|
|
754
|
+
updateOffsets();
|
|
755
|
+
if (callbacks.transient) {
|
|
756
|
+
const image = active;
|
|
757
|
+
const targets = targetsFor(image);
|
|
758
|
+
if (targets.some((target) => !target?.isConnected)) {
|
|
759
|
+
abandon(image, false);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const parts = currentParts(image);
|
|
763
|
+
// Translation changes placement, not pixels. Keep the pre-interaction
|
|
764
|
+
// image until its replacement is ready; resizing still invalidates it.
|
|
765
|
+
if (
|
|
766
|
+
callbacks.opaqueUnderlay ||
|
|
767
|
+
parts.every((part, index) => part.width === image.data.parts[index].width && part.height === image.data.parts[index].height)
|
|
768
|
+
)
|
|
769
|
+
image.data = { ...image.data, parts };
|
|
770
|
+
else cached = undefined;
|
|
771
|
+
// Both requests use the same ordered bridge. Reprojection need not await this optional copy.
|
|
772
|
+
void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => {});
|
|
773
|
+
}
|
|
774
|
+
restoreRevision = callbacks.revision?.() ?? latest?.revision ?? 0;
|
|
775
|
+
ending = true;
|
|
776
|
+
active.sources.forEach((source) => owners.delete(source));
|
|
777
|
+
restoreTimeout = win.setTimeout(() => {
|
|
778
|
+
if (ending) abandon(active, false);
|
|
779
|
+
}, 250);
|
|
780
|
+
callbacks.wake();
|
|
781
|
+
};
|
|
782
|
+
const updated = (token: string | undefined, restoredIDs?: string[], revision?: number) => {
|
|
783
|
+
if (!ending || !active || revision === undefined || revision <= restoreRevision) return;
|
|
784
|
+
if (!callbacks.transient && token) return;
|
|
785
|
+
if (!active.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id)))) return;
|
|
786
|
+
// Retire the Web image at the native restore acknowledgement, not after
|
|
787
|
+
// another complete reconciliation/paint cycle over live glass.
|
|
788
|
+
finishRestoration();
|
|
789
|
+
schedule();
|
|
790
|
+
};
|
|
791
|
+
const invalidate = (nativeHidden = false) => {
|
|
792
|
+
pendingCleanup.forEach((run) => run());
|
|
793
|
+
generation++;
|
|
794
|
+
win.clearTimeout(timer);
|
|
795
|
+
const previous = cached;
|
|
796
|
+
cached = undefined;
|
|
797
|
+
if (nativeHidden || stopped) retiring = undefined;
|
|
798
|
+
if (callbacks.retainPreparedUntilRelease && !active && !nativeHidden && !stopped) {
|
|
799
|
+
if (previous) {
|
|
800
|
+
const pending = {};
|
|
801
|
+
retiring = pending;
|
|
802
|
+
void bounded(call({ action: 'release', token: previous.data.token }))
|
|
803
|
+
.catch(() => {})
|
|
804
|
+
.finally(() => {
|
|
805
|
+
previous.images.forEach((image) => {
|
|
806
|
+
retireImage(image);
|
|
807
|
+
});
|
|
808
|
+
if (retiring !== pending) return;
|
|
809
|
+
retiring = undefined;
|
|
810
|
+
if (cached || active) return;
|
|
811
|
+
// Another session can already own the same host; retire only our own DOM markers.
|
|
812
|
+
for (const target of rendered) {
|
|
813
|
+
if (target.getAttribute(imageMarker) !== preparedKeys.get(target)) {
|
|
814
|
+
rendered.delete(target);
|
|
815
|
+
preparedTargets.delete(target);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
detach();
|
|
819
|
+
preparedKeys.clear();
|
|
820
|
+
win.dispatchEvent(new Event(sourcesChangedEvent));
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (retiring) return;
|
|
824
|
+
}
|
|
825
|
+
dispose(previous);
|
|
826
|
+
if (active) abandon(active, false);
|
|
827
|
+
else {
|
|
828
|
+
detach();
|
|
829
|
+
preparedKeys.clear();
|
|
830
|
+
win.dispatchEvent(new Event(sourcesChangedEvent));
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
const begin = () => {
|
|
834
|
+
const starting = !locallyRequested;
|
|
835
|
+
locallyRequested = true;
|
|
836
|
+
present(starting && projectionRequest.isPaused());
|
|
837
|
+
};
|
|
838
|
+
const end = () => {
|
|
839
|
+
locallyRequested = false;
|
|
840
|
+
if (!projectionRequest.isPaused()) restore();
|
|
841
|
+
};
|
|
842
|
+
const projectionChanged = () =>
|
|
843
|
+
queueMicrotask(() => {
|
|
844
|
+
if (stopped) return;
|
|
845
|
+
if (projectionRequest.isPaused()) present();
|
|
846
|
+
else if (!locallyRequested) restore();
|
|
847
|
+
callbacks.wake();
|
|
848
|
+
});
|
|
849
|
+
let eventListener: Awaited<ReturnType<typeof bindProjectionEvents>> | undefined;
|
|
850
|
+
try {
|
|
851
|
+
if (callbacks.events)
|
|
852
|
+
eventListener = await bindProjectionEvents(callbacks.events, (next, updated) => {
|
|
853
|
+
if (stopped) return;
|
|
854
|
+
if (next) destination = next;
|
|
855
|
+
// Native hide has already happened. Retire images and invalidate pending captures,
|
|
856
|
+
// without calling the plugin's public hide method or disturbing its fallback DOM.
|
|
857
|
+
if (destination === 'none') {
|
|
858
|
+
locallyRequested = false;
|
|
859
|
+
invalidate();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (next === 'web') begin();
|
|
863
|
+
if (next === 'native') {
|
|
864
|
+
end();
|
|
865
|
+
if (projectionRequest.isPaused()) present(true);
|
|
866
|
+
schedule();
|
|
867
|
+
}
|
|
868
|
+
if (updated) changed();
|
|
869
|
+
});
|
|
870
|
+
} catch (error) {
|
|
871
|
+
stopped = true;
|
|
872
|
+
invalidate();
|
|
873
|
+
await bounded(Promise.resolve(listener.remove())).catch(() => {});
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
const stopPause = projectionRequest.subscribe(projectionChanged);
|
|
877
|
+
return {
|
|
878
|
+
get owners(): readonly HTMLElement[] {
|
|
879
|
+
return [...owners];
|
|
880
|
+
},
|
|
881
|
+
get visibleOwners(): readonly HTMLElement[] {
|
|
882
|
+
return [...visibleOwners];
|
|
883
|
+
},
|
|
884
|
+
get preparedOwners(): readonly HTMLElement[] {
|
|
885
|
+
return [...preparedOwners];
|
|
886
|
+
},
|
|
887
|
+
consider,
|
|
888
|
+
changed,
|
|
889
|
+
moved: updateOffsets,
|
|
890
|
+
get token() {
|
|
891
|
+
return ending ? undefined : active?.data.token;
|
|
892
|
+
},
|
|
893
|
+
begin,
|
|
894
|
+
get restoring() {
|
|
895
|
+
return ending;
|
|
896
|
+
},
|
|
897
|
+
get handoffPending() {
|
|
898
|
+
return handoffPending;
|
|
899
|
+
},
|
|
900
|
+
retireRestoration,
|
|
901
|
+
end,
|
|
902
|
+
updated,
|
|
903
|
+
invalidate,
|
|
904
|
+
async destroy() {
|
|
905
|
+
stopped = true;
|
|
906
|
+
stopPause();
|
|
907
|
+
invalidate();
|
|
908
|
+
imagePool.forEach((pool) => pool.forEach((image) => image.remove()));
|
|
909
|
+
imagePool.clear();
|
|
910
|
+
await Promise.allSettled([
|
|
911
|
+
bounded(Promise.resolve().then(() => listener?.remove())),
|
|
912
|
+
bounded(Promise.resolve().then(() => eventListener?.remove())),
|
|
913
|
+
]);
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
type Engine = Awaited<ReturnType<typeof createProjectionEngine>>;
|
|
918
|
+
export type WebProjection = Omit<Engine, 'consider'> & { consider(revision: number, sources: WebProjectionSource[]): void };
|
|
919
|
+
export type WebProjectionBatch = Omit<Engine, 'consider'> & { consider(revision: number, sources: Candidate[]): void };
|
|
920
|
+
|
|
921
|
+
/** One existing fallback DOM, with no separate document or rendering destination. */
|
|
922
|
+
export const createWebProjection = async (
|
|
923
|
+
scope: WebProjectionScope,
|
|
924
|
+
transport: WebProjectionTransport,
|
|
925
|
+
options: WebProjectionOptions,
|
|
926
|
+
): Promise<WebProjection> => {
|
|
927
|
+
const engine = await createProjectionEngine(scope, transport, options);
|
|
928
|
+
const consider = engine.consider;
|
|
929
|
+
return Object.assign(engine, {
|
|
930
|
+
consider: (revision: number, sources: WebProjectionSource[]) =>
|
|
931
|
+
consider(
|
|
932
|
+
revision,
|
|
933
|
+
sources.map((source) => ({ ...source, scope })),
|
|
934
|
+
),
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
/** A single Native transaction for multiple fixed fallback DOMs. */
|
|
939
|
+
export const createWebProjectionBatch = async (
|
|
940
|
+
transport: WebProjectionTransport,
|
|
941
|
+
options: WebProjectionOptions,
|
|
942
|
+
): Promise<WebProjectionBatch> => {
|
|
943
|
+
let engine: Engine | undefined;
|
|
944
|
+
let initializing: Promise<void> | undefined;
|
|
945
|
+
let latest: { revision: number; sources: Candidate[] } | undefined;
|
|
946
|
+
let stopped = false;
|
|
947
|
+
let batchDocument: Document | undefined;
|
|
948
|
+
return {
|
|
949
|
+
get owners() {
|
|
950
|
+
return engine?.owners ?? [];
|
|
951
|
+
},
|
|
952
|
+
get visibleOwners() {
|
|
953
|
+
return engine?.visibleOwners ?? [];
|
|
954
|
+
},
|
|
955
|
+
get preparedOwners() {
|
|
956
|
+
return engine?.preparedOwners ?? [];
|
|
957
|
+
},
|
|
958
|
+
get token() {
|
|
959
|
+
return engine?.token;
|
|
960
|
+
},
|
|
961
|
+
get restoring() {
|
|
962
|
+
return engine?.restoring ?? false;
|
|
963
|
+
},
|
|
964
|
+
get handoffPending() {
|
|
965
|
+
return engine?.handoffPending ?? false;
|
|
966
|
+
},
|
|
967
|
+
consider(revision, sources) {
|
|
968
|
+
if (stopped) return;
|
|
969
|
+
const doc = batchDocument ?? sources[0]?.scope.element.ownerDocument;
|
|
970
|
+
if (
|
|
971
|
+
doc &&
|
|
972
|
+
sources.some(
|
|
973
|
+
(source) =>
|
|
974
|
+
source.scope.element.ownerDocument !== doc ||
|
|
975
|
+
[source.element, ...(source.sources ?? [])].some((element) => element.ownerDocument !== doc),
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
throw new TypeError('All projection sources and fallback scopes must share one Document');
|
|
979
|
+
batchDocument = doc;
|
|
980
|
+
latest = { revision, sources };
|
|
981
|
+
if (engine) {
|
|
982
|
+
engine.consider(revision, sources);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (initializing || !sources.length) return;
|
|
986
|
+
initializing = createProjectionEngine(sources[0].scope, transport, options)
|
|
987
|
+
.then(async (created) => {
|
|
988
|
+
if (stopped) {
|
|
989
|
+
await created.destroy();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
engine = created;
|
|
993
|
+
if (latest) engine.consider(latest.revision, latest.sources);
|
|
994
|
+
})
|
|
995
|
+
.catch(() => {
|
|
996
|
+
initializing = undefined;
|
|
997
|
+
options.wake();
|
|
998
|
+
});
|
|
999
|
+
},
|
|
1000
|
+
changed: (owner) => engine?.changed(owner),
|
|
1001
|
+
moved: () => engine?.moved(),
|
|
1002
|
+
begin: () => engine?.begin(),
|
|
1003
|
+
end: () => engine?.end(),
|
|
1004
|
+
updated: (token, ids, revision) => engine?.updated(token, ids, revision),
|
|
1005
|
+
retireRestoration: () => engine?.retireRestoration(),
|
|
1006
|
+
invalidate: (nativeHidden?: boolean) => engine?.invalidate(nativeHidden),
|
|
1007
|
+
async destroy() {
|
|
1008
|
+
stopped = true;
|
|
1009
|
+
await initializing;
|
|
1010
|
+
await engine?.destroy();
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
};
|