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