@webview-native-ui/engine 0.0.6 → 0.0.8
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 +19 -12
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/native/session.d.ts +5 -0
- package/dist/native/session.js +125 -16
- package/dist/native/updates.d.ts +16 -0
- package/dist/native/updates.js +108 -0
- package/dist/native/values.d.ts +5 -0
- package/dist/native/values.js +27 -0
- package/dist/projection/surface.d.ts +35 -0
- package/dist/projection/surface.js +81 -0
- package/dist/projection/surfaces.d.ts +20 -0
- package/dist/projection/surfaces.js +116 -0
- package/docs/README.md +70 -0
- package/docs/advanced/README.md +20 -0
- package/docs/advanced/android-placement.md +48 -0
- package/docs/advanced/native-handoff.md +89 -0
- package/docs/advanced/renderer.md +148 -0
- package/docs/advanced/update-queue.md +58 -0
- package/docs/advanced/web-projection.md +88 -0
- package/docs/assets/ios-native-capture-comparison.png +0 -0
- package/docs/concepts.md +97 -0
- package/docs/configuration/README.md +29 -0
- package/docs/configuration/capture.md +27 -0
- package/docs/configuration/events.md +35 -0
- package/docs/configuration/ionic-overlays.md +65 -0
- package/docs/configuration/values.md +125 -0
- package/docs/cordova.md +1 -1
- package/docs/{capacitor-plugin.md → getting-started/capacitor.md} +45 -47
- package/docs/{cordova-plugin.md → getting-started/cordova.md} +32 -15
- package/docs/getting-started/initialization.md +79 -0
- package/docs/maintenance.md +38 -8
- package/docs/{projection-containers.md → reference/containers.md} +6 -2
- package/docs/reference/platforms.md +137 -0
- package/docs/reference/projection.md +134 -0
- package/docs/reference/state.md +39 -0
- package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +12 -12
- package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +68 -7
- package/ios/Sources/WebViewNativeUIEngine/Frame.swift +10 -10
- package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +74 -39
- package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +6 -6
- package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +2 -0
- package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +9 -7
- package/package.json +9 -4
- package/docs/projection.md +0 -368
package/README.md
CHANGED
|
@@ -29,24 +29,31 @@ WebView Native UI Engine makes that work reusable. It lets applications combine
|
|
|
29
29
|
|
|
30
30
|
Use the live native view where it belongs, and a prepared visual projection in the DOM when the Web layer needs to own presentation. Restore the interactive native view when it can take over again. An existing DOM container gives the Web projection its place in the application's layout and layer structure.
|
|
31
31
|
|
|
32
|
-
The Engine coordinates this handoff; the integrating renderer still creates and positions its views. A projected image preserves appearance, not live interaction or video. Capture availability and layout support determine when a projection can be used, with an explicit fallback when it cannot. See [
|
|
32
|
+
The Engine coordinates this handoff; the integrating renderer still creates and positions its views. A projected image preserves appearance, not live interaction or video. Capture availability and layout support determine when a projection can be used, with an explicit fallback when it cannot. See [concepts and limits](docs/concepts.md) for the detailed contract and limits.
|
|
33
33
|
|
|
34
|
-
##
|
|
34
|
+
## Choose your starting point
|
|
35
35
|
|
|
36
|
-
Applications gain these capabilities through plugins that integrate the Engine.
|
|
36
|
+
Applications gain these capabilities through plugins that integrate the Engine.
|
|
37
|
+
Plugin authors retain their existing native controls and public APIs; the Engine
|
|
38
|
+
supplies the coordination between native and Web presentation. It supports iOS
|
|
39
|
+
and Android with Capacitor or Cordova. Ionic and Angular are not required.
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
| Start here | Purpose |
|
|
42
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
43
|
+
| [Documentation](docs/README.md) | Reading order: basic initialization, optional behavior, advanced APIs and reference. |
|
|
44
|
+
| [Concepts and limits](docs/concepts.md) | Decide whether projection fits your component. |
|
|
45
|
+
| [Basic initialization](docs/getting-started/initialization.md) | Minimum setup and required inputs, before framework-specific integration. |
|
|
40
46
|
|
|
41
|
-
Native registration starts the Web runtime;
|
|
47
|
+
Native registration starts the Web runtime; the base integration needs no separate
|
|
48
|
+
application-side Engine initializer. The API is pre-1.0: pin matching Engine
|
|
49
|
+
versions across npm and native dependencies. Android placement requires API 23+,
|
|
50
|
+
with composited snapshots available from API 26+.
|
|
42
51
|
|
|
43
|
-
|
|
52
|
+
## Reference and contributing
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
- [Projection behavior and advanced integration](docs/projection.md) — DOM requirements, lifecycle, fallback policies, optional Ionic support and lower-level APIs.
|
|
49
|
-
- [Development and releases](docs/maintenance.md) — repository structure, validation and publishing.
|
|
54
|
+
The [documentation index](docs/README.md) links to configuration, advanced APIs,
|
|
55
|
+
container support and platform contracts. For repository development and publishing,
|
|
56
|
+
see [development and releases](docs/maintenance.md).
|
|
50
57
|
|
|
51
58
|
## License
|
|
52
59
|
|
package/dist/index.d.ts
CHANGED
|
@@ -8,3 +8,6 @@ export type { ProjectionEvents, ProjectionDestination } from './projection/event
|
|
|
8
8
|
export { getOverlayCoordinator, usesOverlayBackdrop, type OverlayChange, type OverlayOptions } from './dom/overlays.js';
|
|
9
9
|
export { getUnprojectedVisibility } from './dom/visibility.js';
|
|
10
10
|
export { bindProjectionPlacement, type ProjectionPlacementBinding } from './projection/placement.js';
|
|
11
|
+
export { createWebProjectionSurfaces } from './projection/surfaces.js';
|
|
12
|
+
export * from './projection/surface.js';
|
|
13
|
+
export { createNativeUpdateQueue, type NativeUpdateSender } from './native/updates.js';
|
package/dist/index.js
CHANGED
|
@@ -7,3 +7,6 @@ export * from './dom/scroll-bindings.js';
|
|
|
7
7
|
export { getOverlayCoordinator, usesOverlayBackdrop } from './dom/overlays.js';
|
|
8
8
|
export { getUnprojectedVisibility } from './dom/visibility.js';
|
|
9
9
|
export { bindProjectionPlacement } from './projection/placement.js';
|
|
10
|
+
export { createWebProjectionSurfaces } from './projection/surfaces.js';
|
|
11
|
+
export * from './projection/surface.js';
|
|
12
|
+
export { createNativeUpdateQueue } from './native/updates.js';
|
package/dist/native/session.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type NativeValueBinding } from './values.js';
|
|
1
2
|
import { type ProjectionDestination } from '../projection/events.js';
|
|
2
3
|
import type { Frame, ScrollPlacement, ListenerHandle, WebProjectionRequest } from '../types.js';
|
|
3
4
|
/** Serialized by the native library. One registration owns one fixed fallback container. */
|
|
@@ -7,6 +8,8 @@ export interface NativeSessionConfiguration {
|
|
|
7
8
|
runtime?: 'capacitor' | 'cordova';
|
|
8
9
|
projectionSelector: string;
|
|
9
10
|
captureDelayMs?: number;
|
|
11
|
+
/** Value reflection is opt-in; native supplies the current values. */
|
|
12
|
+
values?: Record<string, NativeValueBinding>;
|
|
10
13
|
/** Recovery while native is requested. Explicit web/none destinations take precedence. */
|
|
11
14
|
failedFallback?: 'web' | 'native';
|
|
12
15
|
captureMode?: 'composited' | 'view';
|
|
@@ -40,6 +43,8 @@ export interface NativeSessionBridge {
|
|
|
40
43
|
};
|
|
41
44
|
} | {
|
|
42
45
|
action: 'connect';
|
|
46
|
+
} | {
|
|
47
|
+
action: 'values';
|
|
43
48
|
} | {
|
|
44
49
|
action: 'destination';
|
|
45
50
|
destination: ProjectionDestination;
|
package/dist/native/session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyNativeValues } from './values.js';
|
|
1
2
|
import { projectionPlacement } from '../projection/placement.js';
|
|
2
3
|
import { getOverlayCoordinator } from '../dom/overlays.js';
|
|
3
4
|
import { readScrollPlacement } from '../dom/scroll-placement.js';
|
|
@@ -39,9 +40,61 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
39
40
|
}
|
|
40
41
|
};
|
|
41
42
|
let target;
|
|
43
|
+
let valueRequest = 0;
|
|
44
|
+
let cancelValueWait;
|
|
45
|
+
const retireValueRead = () => {
|
|
46
|
+
valueRequest++;
|
|
47
|
+
cancelValueWait?.();
|
|
48
|
+
cancelValueWait = undefined;
|
|
49
|
+
};
|
|
50
|
+
const refreshValues = async () => {
|
|
51
|
+
if (!config.values || !target || stopped)
|
|
52
|
+
return;
|
|
53
|
+
const element = target;
|
|
54
|
+
retireValueRead();
|
|
55
|
+
const request = valueRequest;
|
|
56
|
+
const current = () => !stopped && request === valueRequest && target === element && findTarget() === element && element.isConnected;
|
|
57
|
+
// Defining a custom element installs its setters. Earlier assignment would shadow them.
|
|
58
|
+
if (element.localName.includes('-') &&
|
|
59
|
+
!element.matches(':defined') &&
|
|
60
|
+
Object.values(config.values).some((binding) => binding.property)) {
|
|
61
|
+
await Promise.race([
|
|
62
|
+
element.ownerDocument.defaultView.customElements.whenDefined(element.localName),
|
|
63
|
+
new Promise((resolve) => {
|
|
64
|
+
cancelValueWait = resolve;
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
if (!current())
|
|
68
|
+
return;
|
|
69
|
+
cancelValueWait = undefined;
|
|
70
|
+
}
|
|
71
|
+
const result = await nativeBridge.send({ action: 'values' }).catch(() => undefined);
|
|
72
|
+
if (!current())
|
|
73
|
+
return;
|
|
74
|
+
if (result?.values && typeof result.values === 'object' && !Array.isArray(result.values)) {
|
|
75
|
+
try {
|
|
76
|
+
applyNativeValues(element, result.values, config.values);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.warn('[Native UI Engine] Cannot reflect values', error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
42
83
|
const placementOwner = {};
|
|
43
84
|
let coveredToken;
|
|
44
85
|
let placementEpoch = 0;
|
|
86
|
+
const releases = new Set();
|
|
87
|
+
let cancelReleaseWait = () => { };
|
|
88
|
+
let releaseCancellation = new Promise((resolve) => {
|
|
89
|
+
cancelReleaseWait = resolve;
|
|
90
|
+
});
|
|
91
|
+
const cancelReleases = () => {
|
|
92
|
+
releases.clear();
|
|
93
|
+
cancelReleaseWait();
|
|
94
|
+
releaseCancellation = new Promise((resolve) => {
|
|
95
|
+
cancelReleaseWait = resolve;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
45
98
|
const clearPlacement = () => {
|
|
46
99
|
placementEpoch++;
|
|
47
100
|
coveredToken = undefined;
|
|
@@ -64,6 +117,28 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
64
117
|
send: (request) => {
|
|
65
118
|
if (!config.placementHandoff)
|
|
66
119
|
return nativeBridge.send(request);
|
|
120
|
+
if (request.action === 'visibility' && !request.visible)
|
|
121
|
+
cancelReleases();
|
|
122
|
+
if (request.action === 'visibility' && request.visible && releases.size) {
|
|
123
|
+
const version = generation;
|
|
124
|
+
const epoch = placementEpoch;
|
|
125
|
+
const host = target;
|
|
126
|
+
const cancellation = releaseCancellation;
|
|
127
|
+
// All reveal paths (including scroll fallback) wait for idle release's placement/draw ACK.
|
|
128
|
+
// Re-enter to include releases added while waiting. Hide/teardown cancels the wait.
|
|
129
|
+
return Promise.race([Promise.allSettled([...releases]), cancellation]).then(() => {
|
|
130
|
+
if (stopped ||
|
|
131
|
+
cancellation !== releaseCancellation ||
|
|
132
|
+
version !== generation ||
|
|
133
|
+
epoch !== placementEpoch ||
|
|
134
|
+
host !== target ||
|
|
135
|
+
!host?.isConnected ||
|
|
136
|
+
findTarget() !== target ||
|
|
137
|
+
effectiveDestination() !== 'native')
|
|
138
|
+
return {};
|
|
139
|
+
return bridge.send(request);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
67
142
|
const binding = projectionPlacement(target);
|
|
68
143
|
if (request.action === 'cover') {
|
|
69
144
|
const alreadyCovered = coveredToken === request.token && binding?.owner === placementOwner && binding.projected;
|
|
@@ -105,7 +180,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
105
180
|
const payload = revealing && binding?.owner === placementOwner
|
|
106
181
|
? { ...request, token: ('token' in request ? request.token : coveredToken), placement: binding.measure() }
|
|
107
182
|
: request;
|
|
108
|
-
|
|
183
|
+
const pending = nativeBridge
|
|
109
184
|
.send(payload)
|
|
110
185
|
.then(async (result) => {
|
|
111
186
|
// A native drag can race retirement before its notification reaches JS.
|
|
@@ -128,6 +203,10 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
128
203
|
failed();
|
|
129
204
|
throw error;
|
|
130
205
|
});
|
|
206
|
+
if (request.action !== 'release' || !config.nativeDragHandoff)
|
|
207
|
+
return pending;
|
|
208
|
+
releases.add(pending);
|
|
209
|
+
return pending.finally(() => releases.delete(pending));
|
|
131
210
|
}
|
|
132
211
|
catch (error) {
|
|
133
212
|
failed();
|
|
@@ -138,7 +217,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
138
217
|
let revision = 0;
|
|
139
218
|
let contentRevision = 0;
|
|
140
219
|
let generation = 0;
|
|
141
|
-
let restoring
|
|
220
|
+
let restoring;
|
|
142
221
|
let initializing = false;
|
|
143
222
|
let scheduled = false;
|
|
144
223
|
let reconciling = false;
|
|
@@ -151,6 +230,9 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
151
230
|
return matches.length === 1 && matches[0] instanceof HTMLElement ? matches[0] : undefined;
|
|
152
231
|
};
|
|
153
232
|
const setVisibility = () => {
|
|
233
|
+
const intent = effectiveDestination();
|
|
234
|
+
if (target && target.getAttribute('data-native-ui-destination') !== intent)
|
|
235
|
+
target.setAttribute('data-native-ui-destination', intent);
|
|
154
236
|
if (!target)
|
|
155
237
|
return;
|
|
156
238
|
if (destination === 'web' || (destination === 'native' && placementPending))
|
|
@@ -206,16 +288,16 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
206
288
|
target?.removeAttribute(marker);
|
|
207
289
|
return;
|
|
208
290
|
}
|
|
209
|
-
if (restoring ||
|
|
291
|
+
if (!manager?.restoring || restoring === manager || destination !== 'native')
|
|
210
292
|
return;
|
|
211
|
-
restoring =
|
|
293
|
+
restoring = manager;
|
|
212
294
|
const version = generation;
|
|
213
295
|
const current = manager;
|
|
214
296
|
// The native library acknowledges after the live views have entered a draw pass.
|
|
215
297
|
void bridge
|
|
216
298
|
.send({ action: 'resume' })
|
|
217
299
|
.then(() => {
|
|
218
|
-
if (stopped || version !== generation || current !== manager)
|
|
300
|
+
if (stopped || version !== generation || current !== manager || findTarget() !== target)
|
|
219
301
|
return;
|
|
220
302
|
placementPending = false;
|
|
221
303
|
current.updated(undefined, [sourceID], ++revision);
|
|
@@ -229,33 +311,55 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
229
311
|
void recoverFailedProjection();
|
|
230
312
|
})
|
|
231
313
|
.finally(() => {
|
|
232
|
-
restoring
|
|
314
|
+
if (restoring === current)
|
|
315
|
+
restoring = undefined;
|
|
233
316
|
wake();
|
|
234
317
|
});
|
|
235
318
|
};
|
|
236
319
|
const reconcile = async () => {
|
|
237
|
-
if (stopped || initializing
|
|
320
|
+
if (stopped || initializing)
|
|
238
321
|
return;
|
|
239
|
-
if (manager?.restoring) {
|
|
240
|
-
wake();
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
322
|
const found = findTarget();
|
|
244
323
|
if (found !== target) {
|
|
245
324
|
generation++;
|
|
325
|
+
automaticMotion = false;
|
|
326
|
+
nativeDragging = false;
|
|
327
|
+
clearTimeout(scrollTimer);
|
|
246
328
|
clearPlacement();
|
|
247
329
|
target?.removeAttribute(marker);
|
|
248
|
-
|
|
330
|
+
target?.removeAttribute('data-native-ui-destination');
|
|
331
|
+
retireValueRead();
|
|
332
|
+
// A page/DOM replacement retires its old surface even during a drag or draw ACK.
|
|
333
|
+
// Hide before releasing its image so retirement cannot reveal the departed view.
|
|
334
|
+
const previous = manager;
|
|
335
|
+
const previousTarget = target;
|
|
249
336
|
manager = undefined;
|
|
337
|
+
target = undefined;
|
|
338
|
+
sizeObserver.disconnect();
|
|
339
|
+
refreshScrollListeners();
|
|
340
|
+
try {
|
|
341
|
+
if (previousTarget)
|
|
342
|
+
await bridge.send({ action: 'visibility', visible: false });
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await previous?.destroy();
|
|
346
|
+
}
|
|
250
347
|
if (stopped)
|
|
251
348
|
return;
|
|
252
|
-
target =
|
|
349
|
+
target = findTarget();
|
|
253
350
|
placementPending = false;
|
|
254
|
-
|
|
255
|
-
if (target)
|
|
351
|
+
if (target) {
|
|
256
352
|
sizeObserver.observe(target);
|
|
353
|
+
void refreshValues();
|
|
354
|
+
}
|
|
257
355
|
}
|
|
258
356
|
refreshScrollListeners();
|
|
357
|
+
if (automaticMotion)
|
|
358
|
+
return;
|
|
359
|
+
if (manager?.restoring) {
|
|
360
|
+
wake();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
259
363
|
destination = effectiveDestination();
|
|
260
364
|
if (!target) {
|
|
261
365
|
await bridge.send({ action: 'visibility', visible: false });
|
|
@@ -295,7 +399,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
295
399
|
if (destination === 'native') {
|
|
296
400
|
const version = generation;
|
|
297
401
|
await bridge.send({ action: 'visibility', visible: true });
|
|
298
|
-
if (stopped || version !== generation || destination !== 'native')
|
|
402
|
+
if (stopped || version !== generation || destination !== 'native' || automaticMotion)
|
|
299
403
|
return;
|
|
300
404
|
setVisibility();
|
|
301
405
|
scrollOwner = [...projectionAncestors(target)].find((element) => {
|
|
@@ -516,6 +620,8 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
516
620
|
if (stopped)
|
|
517
621
|
return;
|
|
518
622
|
stopped = true;
|
|
623
|
+
cancelReleases();
|
|
624
|
+
retireValueRead();
|
|
519
625
|
clearPlacement();
|
|
520
626
|
generation++;
|
|
521
627
|
stopOverlays();
|
|
@@ -530,6 +636,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
530
636
|
scrollAncestors.clear();
|
|
531
637
|
clearTimeout(scrollTimer);
|
|
532
638
|
target?.removeAttribute(marker);
|
|
639
|
+
target?.removeAttribute('data-native-ui-destination');
|
|
533
640
|
style.remove();
|
|
534
641
|
await Promise.allSettled([Promise.resolve(events?.remove()), manager?.destroy()]);
|
|
535
642
|
};
|
|
@@ -552,6 +659,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
552
659
|
applyDestination(effectiveDestination(), true);
|
|
553
660
|
}
|
|
554
661
|
if (updated) {
|
|
662
|
+
void refreshValues();
|
|
555
663
|
contentRevision++;
|
|
556
664
|
manager?.changed();
|
|
557
665
|
if (destination === 'native')
|
|
@@ -574,6 +682,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
|
|
|
574
682
|
hide: () => {
|
|
575
683
|
requestedDestination = destination = 'none';
|
|
576
684
|
generation++;
|
|
685
|
+
cancelReleases();
|
|
577
686
|
automaticMotion = false;
|
|
578
687
|
nativeDragging = false;
|
|
579
688
|
clearTimeout(scrollTimer);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Send only replaceable state from an update pass, never user actions. */
|
|
2
|
+
export type NativeUpdateSender = <T>(request: () => Promise<T>, validate?: (result: T) => void) => Promise<T | undefined>;
|
|
3
|
+
/** Coalesce current-state reads and serialize them across asynchronous native acknowledgements. */
|
|
4
|
+
export declare const createNativeUpdateQueue: (window: Pick<Window, "requestAnimationFrame" | "cancelAnimationFrame">, options: {
|
|
5
|
+
update(send: NativeUpdateSender): Promise<void>;
|
|
6
|
+
failed(error: unknown): void | Promise<void>;
|
|
7
|
+
retry(): void;
|
|
8
|
+
reconnect?: boolean;
|
|
9
|
+
}) => {
|
|
10
|
+
request: () => void;
|
|
11
|
+
readonly dirty: boolean;
|
|
12
|
+
readonly running: boolean;
|
|
13
|
+
readonly waiting: boolean;
|
|
14
|
+
reconnected(): void;
|
|
15
|
+
destroy(): void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Coalesce current-state reads and serialize them across asynchronous native acknowledgements. */
|
|
2
|
+
export const createNativeUpdateQueue = (window, options) => {
|
|
3
|
+
let frame = 0;
|
|
4
|
+
let dirty = false;
|
|
5
|
+
let running = false;
|
|
6
|
+
let stopped = false;
|
|
7
|
+
let retrying = false;
|
|
8
|
+
let waiting = false;
|
|
9
|
+
let reconciled = false;
|
|
10
|
+
let passFailed = false;
|
|
11
|
+
const request = () => {
|
|
12
|
+
if (stopped)
|
|
13
|
+
return;
|
|
14
|
+
dirty = true;
|
|
15
|
+
if (!running && !waiting && !frame)
|
|
16
|
+
frame = window.requestAnimationFrame(() => void run());
|
|
17
|
+
};
|
|
18
|
+
const waitForConnection = (error) => {
|
|
19
|
+
if (!options.reconnect || reconciled)
|
|
20
|
+
throw error;
|
|
21
|
+
waiting = true;
|
|
22
|
+
dirty = false;
|
|
23
|
+
};
|
|
24
|
+
const send = async (operation, validate) => {
|
|
25
|
+
if (stopped || waiting || passFailed)
|
|
26
|
+
return;
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await operation();
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (stopped)
|
|
33
|
+
return;
|
|
34
|
+
passFailed = true;
|
|
35
|
+
if (retrying)
|
|
36
|
+
waitForConnection(error);
|
|
37
|
+
else {
|
|
38
|
+
retrying = true;
|
|
39
|
+
options.retry();
|
|
40
|
+
request();
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (stopped)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
validate?.(result);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
passFailed = true;
|
|
51
|
+
waitForConnection(error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
const run = async () => {
|
|
57
|
+
frame = 0;
|
|
58
|
+
if (stopped || waiting)
|
|
59
|
+
return;
|
|
60
|
+
dirty = false;
|
|
61
|
+
running = true;
|
|
62
|
+
passFailed = false;
|
|
63
|
+
try {
|
|
64
|
+
await options.update(send);
|
|
65
|
+
if (!passFailed)
|
|
66
|
+
retrying = waiting = reconciled = false;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (stopped)
|
|
70
|
+
return;
|
|
71
|
+
// Retire the queue before reporting terminal failure; no late update can restart it.
|
|
72
|
+
stopped = true;
|
|
73
|
+
await options.failed(error);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
running = false;
|
|
77
|
+
if (dirty)
|
|
78
|
+
request();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
request,
|
|
83
|
+
get dirty() {
|
|
84
|
+
return dirty;
|
|
85
|
+
},
|
|
86
|
+
get running() {
|
|
87
|
+
return running;
|
|
88
|
+
},
|
|
89
|
+
get waiting() {
|
|
90
|
+
return waiting;
|
|
91
|
+
},
|
|
92
|
+
reconnected() {
|
|
93
|
+
if (stopped)
|
|
94
|
+
return;
|
|
95
|
+
if (waiting) {
|
|
96
|
+
reconciled = true;
|
|
97
|
+
retrying = waiting = false;
|
|
98
|
+
}
|
|
99
|
+
request();
|
|
100
|
+
},
|
|
101
|
+
destroy() {
|
|
102
|
+
stopped = true;
|
|
103
|
+
dirty = false;
|
|
104
|
+
window.cancelAnimationFrame(frame);
|
|
105
|
+
frame = 0;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Reflect native state without inventing component-specific input events. */
|
|
2
|
+
export interface NativeValueBinding {
|
|
3
|
+
property?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const applyNativeValues: (target: HTMLElement, values: Record<string, unknown>, bindings: Record<string, NativeValueBinding>) => void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const applyNativeValues = (target, values, bindings) => {
|
|
2
|
+
for (const [key, value] of Object.entries(values)) {
|
|
3
|
+
if (!/^[a-z][a-z0-9-]*$/.test(key))
|
|
4
|
+
throw new TypeError('Native value keys must be lowercase attribute names');
|
|
5
|
+
const binding = Object.hasOwn(bindings, key) ? bindings[key] : undefined;
|
|
6
|
+
const property = binding?.property;
|
|
7
|
+
if (property &&
|
|
8
|
+
(!/^[a-zA-Z][\w]*$/.test(property) ||
|
|
9
|
+
/^on/i.test(property) ||
|
|
10
|
+
['innerHTML', 'outerHTML', 'textContent', 'innerText', 'style', 'constructor', 'prototype'].includes(property)))
|
|
11
|
+
throw new TypeError('Native value binding requires a data property');
|
|
12
|
+
const serialized = JSON.stringify(value);
|
|
13
|
+
if (serialized === undefined)
|
|
14
|
+
throw new TypeError('Native values must be JSON values');
|
|
15
|
+
if (property) {
|
|
16
|
+
const element = target;
|
|
17
|
+
if (JSON.stringify(element[property]) !== serialized)
|
|
18
|
+
element[property] = value;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const attribute = `data-native-ui-value-${key}`;
|
|
22
|
+
const text = typeof value === 'string' ? value : serialized;
|
|
23
|
+
if (target.getAttribute(attribute) !== text)
|
|
24
|
+
target.setAttribute(attribute, text);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type WebProjectionOptions } from './web.js';
|
|
2
|
+
import type { WebProjectionSource, WebProjectionTransport } from '../types.js';
|
|
3
|
+
export interface WebProjectionSurfaceOptions extends WebProjectionOptions {
|
|
4
|
+
namespace: string;
|
|
5
|
+
target(source: WebProjectionSource): HTMLElement | undefined;
|
|
6
|
+
/** Only renderer-owned fallback hosts may move into another overlay layer. */
|
|
7
|
+
canRelocate?(target: HTMLElement): boolean;
|
|
8
|
+
}
|
|
9
|
+
/** A renderer supplies placement policy; the Engine owns the complete image handoff. */
|
|
10
|
+
export declare const createWebProjectionSurface: (transport: WebProjectionTransport, options: WebProjectionSurfaceOptions) => Promise<Omit<{
|
|
11
|
+
readonly owners: readonly HTMLElement[];
|
|
12
|
+
readonly visibleOwners: readonly HTMLElement[];
|
|
13
|
+
readonly preparedOwners: readonly HTMLElement[];
|
|
14
|
+
consider: (revision: number, candidates: import("../types.js").ScopedWebProjectionSource[]) => void;
|
|
15
|
+
changed: (scrollOwner?: EventTarget) => void;
|
|
16
|
+
moved: () => void;
|
|
17
|
+
readonly token: string | undefined;
|
|
18
|
+
begin: () => void;
|
|
19
|
+
readonly restoring: boolean;
|
|
20
|
+
readonly handoffPending: boolean;
|
|
21
|
+
retireRestoration: () => void;
|
|
22
|
+
end: () => void;
|
|
23
|
+
updated: (token: string | undefined, restoredIDs?: string[], revision?: number) => void;
|
|
24
|
+
invalidate: (nativeHidden?: boolean) => void;
|
|
25
|
+
destroy(): Promise<void>;
|
|
26
|
+
}, "consider"> & {
|
|
27
|
+
consider(revision: number, sources: import("../types.js").ScopedWebProjectionSource[]): void;
|
|
28
|
+
} & {
|
|
29
|
+
consider(revision: number, sources: WebProjectionSource[]): void;
|
|
30
|
+
begin(layer?: HTMLElement): void;
|
|
31
|
+
updated(token: string | undefined, ids?: string[], revision?: number): void;
|
|
32
|
+
invalidate(nativeHidden?: boolean): void;
|
|
33
|
+
destroy(): Promise<void>;
|
|
34
|
+
}>;
|
|
35
|
+
export type WebProjectionSurface = Awaited<ReturnType<typeof createWebProjectionSurface>>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createWebProjectionBatch } from './web.js';
|
|
2
|
+
import { createWebProjectionScope } from './scope.js';
|
|
3
|
+
/** A renderer supplies placement policy; the Engine owns the complete image handoff. */
|
|
4
|
+
export const createWebProjectionSurface = async (transport, options) => {
|
|
5
|
+
const scopes = new WeakMap();
|
|
6
|
+
const moved = new Map();
|
|
7
|
+
const restorePlacement = () => {
|
|
8
|
+
for (const [element, saved] of [...moved].reverse()) {
|
|
9
|
+
if (element.parentNode === saved.layer && saved.parent.isConnected)
|
|
10
|
+
saved.parent.insertBefore(element, saved.next?.parentNode === saved.parent ? saved.next : null);
|
|
11
|
+
}
|
|
12
|
+
moved.clear();
|
|
13
|
+
};
|
|
14
|
+
const projection = await createWebProjectionBatch(transport, {
|
|
15
|
+
...options,
|
|
16
|
+
wake: () => {
|
|
17
|
+
if (!projection.token && !projection.restoring)
|
|
18
|
+
restorePlacement();
|
|
19
|
+
options.wake();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
let latest = [];
|
|
23
|
+
const { consider, begin, updated, invalidate, destroy } = projection;
|
|
24
|
+
return Object.assign(projection, {
|
|
25
|
+
consider(revision, sources) {
|
|
26
|
+
latest = sources;
|
|
27
|
+
consider(revision, sources.flatMap((source) => {
|
|
28
|
+
const target = options.target(source);
|
|
29
|
+
if (!target?.isConnected || target.ownerDocument !== source.element.ownerDocument)
|
|
30
|
+
return [];
|
|
31
|
+
let scope = scopes.get(target);
|
|
32
|
+
if (!scope) {
|
|
33
|
+
scope = createWebProjectionScope(target, options.namespace);
|
|
34
|
+
scopes.set(target, scope);
|
|
35
|
+
}
|
|
36
|
+
return [{ ...source, scope }];
|
|
37
|
+
}));
|
|
38
|
+
},
|
|
39
|
+
begin(layer) {
|
|
40
|
+
if (layer && !layer.isConnected)
|
|
41
|
+
return;
|
|
42
|
+
if (layer && !projection.token) {
|
|
43
|
+
for (const source of latest) {
|
|
44
|
+
if (!projection.preparedOwners.includes(source.element))
|
|
45
|
+
continue;
|
|
46
|
+
const target = options.target(source);
|
|
47
|
+
if (!target?.isConnected ||
|
|
48
|
+
!target.parentNode ||
|
|
49
|
+
target.ownerDocument !== layer.ownerDocument ||
|
|
50
|
+
layer.contains(target) ||
|
|
51
|
+
target.contains(layer) ||
|
|
52
|
+
!options.canRelocate?.(target))
|
|
53
|
+
continue;
|
|
54
|
+
if (!moved.has(target))
|
|
55
|
+
moved.set(target, { parent: target.parentNode, next: target.nextSibling, layer });
|
|
56
|
+
layer.append(target);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
begin();
|
|
60
|
+
if (!projection.token)
|
|
61
|
+
restorePlacement();
|
|
62
|
+
},
|
|
63
|
+
updated(token, ids, revision) {
|
|
64
|
+
updated(token, ids, revision);
|
|
65
|
+
if (!projection.restoring && !projection.token)
|
|
66
|
+
restorePlacement();
|
|
67
|
+
},
|
|
68
|
+
invalidate(nativeHidden) {
|
|
69
|
+
invalidate(nativeHidden);
|
|
70
|
+
restorePlacement();
|
|
71
|
+
},
|
|
72
|
+
async destroy() {
|
|
73
|
+
try {
|
|
74
|
+
await destroy();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
restorePlacement();
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WebProjectionSurface } from './surface.js';
|
|
2
|
+
/** Owns asynchronous projection creation and disposal for DOM-backed surfaces. */
|
|
3
|
+
export declare const createWebProjectionSurfaces: <Owner extends Node, Surface extends WebProjectionSurface>(options?: {
|
|
4
|
+
ignoreTarget?(element: Element): boolean;
|
|
5
|
+
}) => {
|
|
6
|
+
get: (owner: Owner) => Surface | undefined;
|
|
7
|
+
keys: () => MapIterator<Owner>;
|
|
8
|
+
values: () => Surface[];
|
|
9
|
+
changed: (owner?: EventTarget) => void;
|
|
10
|
+
invalidate: () => void;
|
|
11
|
+
followMotion: (moving: ReadonlyMap<HTMLElement, unknown>, target?: (source: HTMLElement) => HTMLElement) => void;
|
|
12
|
+
end: () => void;
|
|
13
|
+
consider: (revision: number, sources: import("../types.js").WebProjectionSource[]) => void;
|
|
14
|
+
updated: (token: string | undefined, restoredIDs?: string[] | undefined, revision?: number | undefined) => void;
|
|
15
|
+
readonly restoring: boolean;
|
|
16
|
+
readonly handoffPending: boolean;
|
|
17
|
+
ensure(owner: Owner, create: () => Promise<Surface>): Promise<Surface | undefined>;
|
|
18
|
+
delete: (owner: Owner) => Promise<void>;
|
|
19
|
+
destroy(): Promise<void>;
|
|
20
|
+
};
|