@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.
Files changed (45) hide show
  1. package/README.md +19 -12
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/native/session.d.ts +5 -0
  5. package/dist/native/session.js +125 -16
  6. package/dist/native/updates.d.ts +16 -0
  7. package/dist/native/updates.js +108 -0
  8. package/dist/native/values.d.ts +5 -0
  9. package/dist/native/values.js +27 -0
  10. package/dist/projection/surface.d.ts +35 -0
  11. package/dist/projection/surface.js +81 -0
  12. package/dist/projection/surfaces.d.ts +20 -0
  13. package/dist/projection/surfaces.js +116 -0
  14. package/docs/README.md +70 -0
  15. package/docs/advanced/README.md +20 -0
  16. package/docs/advanced/android-placement.md +48 -0
  17. package/docs/advanced/native-handoff.md +89 -0
  18. package/docs/advanced/renderer.md +148 -0
  19. package/docs/advanced/update-queue.md +58 -0
  20. package/docs/advanced/web-projection.md +88 -0
  21. package/docs/assets/ios-native-capture-comparison.png +0 -0
  22. package/docs/concepts.md +97 -0
  23. package/docs/configuration/README.md +29 -0
  24. package/docs/configuration/capture.md +27 -0
  25. package/docs/configuration/events.md +35 -0
  26. package/docs/configuration/ionic-overlays.md +65 -0
  27. package/docs/configuration/values.md +125 -0
  28. package/docs/cordova.md +1 -1
  29. package/docs/{capacitor-plugin.md → getting-started/capacitor.md} +45 -47
  30. package/docs/{cordova-plugin.md → getting-started/cordova.md} +32 -15
  31. package/docs/getting-started/initialization.md +79 -0
  32. package/docs/maintenance.md +38 -8
  33. package/docs/{projection-containers.md → reference/containers.md} +6 -2
  34. package/docs/reference/platforms.md +137 -0
  35. package/docs/reference/projection.md +134 -0
  36. package/docs/reference/state.md +39 -0
  37. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +12 -12
  38. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +68 -7
  39. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +10 -10
  40. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +74 -39
  41. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +6 -6
  42. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +2 -0
  43. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +9 -7
  44. package/package.json +9 -4
  45. 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 [projection behavior](docs/projection.md) for the detailed contract and limits.
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
- ## Integration
34
+ ## Choose your starting point
35
35
 
36
- Applications gain these capabilities through plugins that integrate the Engine. Capacitor and Cordova plugin authors add it as a library dependency while retaining their existing native controls and public APIs. The same Engine supports iOS and Android. Ionic and Angular are not required; Ionic overlay coordination is optional.
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
- - [Capacitor plugin guide](docs/capacitor-plugin.md) — install dependencies and add registration, events and teardown to your existing plugin.
39
- - [Cordova plugin guide](docs/cordova-plugin.md) — integrate the same Engine with Cordova's lifecycle and resource handler, without a Capacitor dependency.
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; applications do not need a separate Engine initializer for the base integration. Keep your plugin's normal API and notifications.
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
- The current API is pre-1.0. Use matching, pinned Engine versions across npm and native dependencies. Android placement requires API 23+, with composited snapshots available from API 26+.
52
+ ## Reference and contributing
44
53
 
45
- ## Further reading
46
-
47
- - [Projection container support](docs/projection-containers.md) — supported HTML and Ionic destinations, sizing conditions and wrapper examples.
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';
@@ -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;
@@ -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
- return nativeBridge
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 = false;
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 || !manager?.restoring || destination !== 'native')
291
+ if (!manager?.restoring || restoring === manager || destination !== 'native')
210
292
  return;
211
- restoring = true;
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 = false;
314
+ if (restoring === current)
315
+ restoring = undefined;
233
316
  wake();
234
317
  });
235
318
  };
236
319
  const reconcile = async () => {
237
- if (stopped || initializing || automaticMotion)
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
- await manager?.destroy();
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 = found;
349
+ target = findTarget();
253
350
  placementPending = false;
254
- sizeObserver.disconnect();
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
+ };