@webview-native-ui/engine 0.0.0-stage → 0.0.1

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 (47) hide show
  1. package/LICENSE +18 -0
  2. package/NOTICE +39 -0
  3. package/README.md +623 -2
  4. package/WebViewNativeUIEngine.podspec +17 -0
  5. package/dist/bridge.d.ts +4 -0
  6. package/dist/bridge.js +15 -0
  7. package/dist/geometry.d.ts +2 -0
  8. package/dist/geometry.js +25 -0
  9. package/dist/index.d.ts +10 -0
  10. package/dist/index.js +9 -0
  11. package/dist/ionic.d.ts +16 -0
  12. package/dist/ionic.js +31 -0
  13. package/dist/native-bootstrap.d.ts +8 -0
  14. package/dist/native-bootstrap.js +138 -0
  15. package/dist/native-session.d.ts +48 -0
  16. package/dist/native-session.js +519 -0
  17. package/dist/overlays.d.ts +42 -0
  18. package/dist/overlays.js +181 -0
  19. package/dist/projection-events.d.ts +12 -0
  20. package/dist/projection-events.js +44 -0
  21. package/dist/projection-placement.d.ts +14 -0
  22. package/dist/projection-placement.js +18 -0
  23. package/dist/runtime.d.ts +2 -0
  24. package/dist/runtime.js +11 -0
  25. package/dist/scroll-bindings.d.ts +11 -0
  26. package/dist/scroll-bindings.js +55 -0
  27. package/dist/scroll-placement.d.ts +5 -0
  28. package/dist/scroll-placement.js +85 -0
  29. package/dist/snapshot-styles.d.ts +2 -0
  30. package/dist/snapshot-styles.js +14 -0
  31. package/dist/types.d.ts +88 -0
  32. package/dist/types.js +1 -0
  33. package/dist/visibility.d.ts +6 -0
  34. package/dist/visibility.js +29 -0
  35. package/dist/web-projection-scope.d.ts +6 -0
  36. package/dist/web-projection-scope.js +8 -0
  37. package/dist/web-projection.d.ts +50 -0
  38. package/dist/web-projection.js +835 -0
  39. package/docs/cordova.md +125 -0
  40. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +16 -0
  41. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +256 -0
  42. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +15 -0
  43. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +311 -0
  44. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +14 -0
  45. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +27 -0
  46. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +117 -0
  47. package/package.json +64 -4
@@ -0,0 +1,181 @@
1
+ /** Whether a Web overlay covers the underlying surface, independently of its renderer. */
2
+ export const usesOverlayBackdrop = (element, breakpoint) => {
3
+ if (element.matches('ion-toast, ion-menu'))
4
+ return false;
5
+ const overlay = element;
6
+ if (overlay.showBackdrop === false)
7
+ return false;
8
+ if (!element.matches('ion-modal'))
9
+ return true;
10
+ const height = breakpoint ?? overlay.initialBreakpoint;
11
+ return height === undefined || height > (overlay.backdropBreakpoint ?? 0);
12
+ };
13
+ const key = Symbol.for('@webview-native-ui/engine/overlays/v1');
14
+ const createCoordinator = (doc) => {
15
+ const active = new Set();
16
+ const native = new Set();
17
+ const listeners = new Set();
18
+ const breakpoints = new WeakMap();
19
+ const dismissed = new WeakSet();
20
+ const registrations = new Map();
21
+ let definitions = [];
22
+ let options = {};
23
+ let routes = new Map();
24
+ let observer;
25
+ const notify = (change) => {
26
+ for (const listener of [...listeners]) {
27
+ try {
28
+ listener(change);
29
+ }
30
+ catch (error) {
31
+ console.error('Native UI overlay listener failed', error);
32
+ }
33
+ }
34
+ };
35
+ const accept = (element, phase, event) => {
36
+ if (phase === 'willPresent' || phase === 'didPresent') {
37
+ dismissed.delete(element);
38
+ active.add(element);
39
+ }
40
+ else if (phase === 'didDismiss') {
41
+ dismissed.add(element);
42
+ active.delete(element);
43
+ native.delete(element);
44
+ breakpoints.delete(element);
45
+ }
46
+ const breakpoint = event?.detail?.breakpoint;
47
+ // Zero begins sheet dismissal; retain coverage until DidDismiss retires the surface.
48
+ if (typeof breakpoint === 'number' && breakpoint > 0)
49
+ breakpoints.set(element, breakpoint);
50
+ notify({ element, phase, event });
51
+ };
52
+ const refresh = (initial = false) => {
53
+ let changed = false;
54
+ for (const element of active)
55
+ if (!element.isConnected || element.ownerDocument !== doc || !definitions.some(({ selector }) => element.matches(selector))) {
56
+ active.delete(element);
57
+ native.delete(element);
58
+ changed = true;
59
+ }
60
+ for (const element of doc.querySelectorAll(definitions.map(({ selector }) => selector).join(','))) {
61
+ if (!active.has(element) &&
62
+ !dismissed.has(element) &&
63
+ (initial || !element.matches('ion-popover')) &&
64
+ (element.presented || element.classList.contains('show-menu'))) {
65
+ active.add(element);
66
+ changed = true;
67
+ }
68
+ }
69
+ if (changed)
70
+ notify({ phase: 'change' });
71
+ };
72
+ return {
73
+ get active() {
74
+ return active;
75
+ },
76
+ subscribe(listener) {
77
+ listeners.add(listener);
78
+ return () => {
79
+ listeners.delete(listener);
80
+ };
81
+ },
82
+ /** Renderer ownership is explicit because a child native window is outside the DOM stack. */
83
+ setNative(element, value) {
84
+ if (native.has(element) === value)
85
+ return;
86
+ if (value)
87
+ native.add(element);
88
+ else
89
+ native.delete(element);
90
+ notify({ element, phase: 'change' });
91
+ },
92
+ isExcluded(element) {
93
+ return !!options.exclude && element.matches(options.exclude);
94
+ },
95
+ isNative(element) {
96
+ return native.has(element);
97
+ },
98
+ get stack() {
99
+ const stack = [...active].filter((element) => element.isConnected && element.ownerDocument === doc);
100
+ const order = (element) => {
101
+ const z = Number.parseFloat(doc.defaultView.getComputedStyle(element).zIndex);
102
+ return Number.isFinite(z) ? z : (element.overlayIndex ?? 0);
103
+ };
104
+ stack.sort((a, b) => order(a) - order(b));
105
+ return stack;
106
+ },
107
+ isCovered(target) {
108
+ const stack = this.stack;
109
+ const owner = stack.reduce((last, element, index) => (element.contains(target) ? index : last), -1);
110
+ return stack.some((element, index) => {
111
+ if (native.has(element) || (options.exclude && element.matches(options.exclude)))
112
+ return false;
113
+ const override = options.covers?.(element, target);
114
+ if (override !== undefined)
115
+ return override;
116
+ return (index > owner &&
117
+ !element.contains(target) &&
118
+ (element.matches('ion-menu') || usesOverlayBackdrop(element, breakpoints.get(element))));
119
+ });
120
+ },
121
+ enable(defaults, config) {
122
+ const nextOptions = { ...options, ...config };
123
+ const nextDefinitions = [...defaults, ...(nextOptions.additional ?? [])];
124
+ if (nextOptions.exclude)
125
+ doc.querySelector(nextOptions.exclude);
126
+ const nextRoutes = new Map();
127
+ for (const definition of nextDefinitions) {
128
+ doc.querySelector(definition.selector);
129
+ for (const [phase, name] of Object.entries(definition.events)) {
130
+ const existing = nextRoutes.get(name);
131
+ if (existing && existing.phase !== phase)
132
+ throw new TypeError(`Overlay event "${name}" has conflicting phases`);
133
+ nextRoutes.set(name, {
134
+ phase: phase,
135
+ selector: existing ? `${existing.selector}, ${definition.selector}` : definition.selector,
136
+ });
137
+ }
138
+ }
139
+ options = nextOptions;
140
+ definitions = nextDefinitions;
141
+ routes = nextRoutes;
142
+ for (const name of routes.keys()) {
143
+ if (registrations.has(name))
144
+ continue;
145
+ const listener = (event) => {
146
+ const route = routes.get(name);
147
+ const element = event
148
+ .composedPath()
149
+ .find((node) => node instanceof doc.defaultView.HTMLElement && node.matches(route.selector));
150
+ if (element)
151
+ accept(element, route.phase, event);
152
+ };
153
+ doc.addEventListener(name, listener, true);
154
+ registrations.set(name, () => doc.removeEventListener(name, listener, true));
155
+ }
156
+ for (const [name, remove] of registrations)
157
+ if (!routes.has(name)) {
158
+ remove();
159
+ registrations.delete(name);
160
+ }
161
+ const initial = !observer;
162
+ if (!observer) {
163
+ observer = new MutationObserver(() => refresh());
164
+ observer.observe(doc.documentElement, { childList: true, subtree: true });
165
+ doc.addEventListener('nativeUIOverlayAdopted', (event) => {
166
+ const element = event.detail;
167
+ if (element?.ownerDocument === doc)
168
+ accept(element, 'willPresent', event);
169
+ });
170
+ }
171
+ refresh(initial);
172
+ if (initial || config)
173
+ notify({ phase: 'change' });
174
+ },
175
+ };
176
+ };
177
+ /** Access is inert: only the explicit framework initializer installs DOM listeners. */
178
+ export const getOverlayCoordinator = (doc) => {
179
+ const shared = doc;
180
+ return (shared[key] ??= createCoordinator(doc));
181
+ };
@@ -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 './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,2 @@
1
+ /** Use the host's existing file server; never initialize a framework bridge. */
2
+ export declare const projectionURL: (url: string) => string;
@@ -0,0 +1,11 @@
1
+ /** Use the host's existing file server; never initialize a framework bridge. */
2
+ export const projectionURL = (url) => {
3
+ const host = globalThis;
4
+ if (host.Capacitor?.convertFileSrc)
5
+ return host.Capacitor.convertFileSrc(url);
6
+ if (!url.startsWith('file:'))
7
+ return url;
8
+ if (host.WkWebView?.convertFilePath)
9
+ return host.WkWebView.convertFilePath(url);
10
+ throw new Error('Native UI file URL requires a host resource handler');
11
+ };
@@ -0,0 +1,11 @@
1
+ import type { WebProjectionSource } from './types.js';
2
+ /** Coordinate ownership, independent of any plugin's update transport or rendering. */
3
+ export declare const createScrollBindings: () => {
4
+ reuse: (candidate: WebProjectionSource, width: number) => boolean;
5
+ acknowledge(sources: WebProjectionSource[], width: number): void;
6
+ changed(owner: HTMLElement): void;
7
+ settled(owner: HTMLElement): void;
8
+ eligible(candidate: WebProjectionSource, width: number): boolean;
9
+ acquiring(sources: WebProjectionSource[], width: number): Map<HTMLElement, number>;
10
+ unconfirmed(sources: WebProjectionSource[], acquiring: Map<HTMLElement, number>): Set<HTMLElement>;
11
+ };
@@ -0,0 +1,55 @@
1
+ /** Coordinate ownership, independent of any plugin's update transport or rendering. */
2
+ export const createScrollBindings = () => {
3
+ let acknowledged = [];
4
+ let viewportWidth = 0;
5
+ const motion = new WeakMap();
6
+ const reuse = (candidate, width) => {
7
+ const next = candidate.control.scroll;
8
+ if (!candidate.scrollElement || !next || viewportWidth !== width)
9
+ return false;
10
+ return acknowledged.some((previous) => {
11
+ const current = previous.control.scroll;
12
+ return (previous.scrollElement === candidate.scrollElement &&
13
+ current?.id === next.id &&
14
+ current.contentWidth === next.contentWidth &&
15
+ current.contentHeight === next.contentHeight &&
16
+ ['viewport', 'clip'].every((key) => ['x', 'y', 'width', 'height'].every((axis) => current[key][axis] === next[key][axis])));
17
+ });
18
+ };
19
+ return {
20
+ reuse,
21
+ acknowledge(sources, width) {
22
+ acknowledged = sources;
23
+ viewportWidth = width;
24
+ },
25
+ changed(owner) {
26
+ motion.set(owner, { version: (motion.get(owner)?.version ?? 0) + 1, moving: true });
27
+ },
28
+ settled(owner) {
29
+ const state = motion.get(owner);
30
+ if (state)
31
+ state.moving = false;
32
+ },
33
+ eligible(candidate, width) {
34
+ return !candidate.scrollElement || !motion.get(candidate.scrollElement)?.moving || reuse(candidate, width);
35
+ },
36
+ acquiring(sources, width) {
37
+ return new Map(sources.flatMap((source) => source.scrollElement && !reuse(source, width)
38
+ ? [[source.scrollElement, motion.get(source.scrollElement)?.version ?? 0]]
39
+ : []));
40
+ },
41
+ unconfirmed(sources, acquiring) {
42
+ return new Set(sources.flatMap((source) => {
43
+ const owner = source.scrollElement, sample = source.control.scroll;
44
+ return owner &&
45
+ sample &&
46
+ acquiring.has(owner) &&
47
+ (acquiring.get(owner) !== (motion.get(owner)?.version ?? 0) ||
48
+ owner.scrollLeft !== sample.offsetX ||
49
+ owner.scrollTop !== sample.offsetY)
50
+ ? [owner]
51
+ : [];
52
+ }));
53
+ },
54
+ };
55
+ };
@@ -0,0 +1,5 @@
1
+ import type { ScrollPlacement as NativeUIScrollPlacement } from './types.js';
2
+ type Identify = (element: HTMLElement) => string;
3
+ /** Layout-only measurement. Native owns subsequent scroll offsets. No private Ionic APIs. */
4
+ export declare const readScrollPlacement: (element: HTMLElement, owner: HTMLElement, id: Identify) => NativeUIScrollPlacement | undefined;
5
+ export {};
@@ -0,0 +1,85 @@
1
+ import { projectionAncestors } from './geometry.js';
2
+ const frame = ({ x, y, width, height }) => ({ x, y, width, height });
3
+ const viewportFrame = (element) => {
4
+ const bounds = element.getBoundingClientRect();
5
+ const style = getComputedStyle(element);
6
+ const left = parseFloat(style.borderLeftWidth) || 0;
7
+ const top = parseFloat(style.borderTopWidth) || 0;
8
+ return {
9
+ x: bounds.left + left,
10
+ y: bounds.top + top,
11
+ width: bounds.width - left - (parseFloat(style.borderRightWidth) || 0),
12
+ height: bounds.height - top - (parseFloat(style.borderBottomWidth) || 0),
13
+ };
14
+ };
15
+ /** Layout-only measurement. Native owns subsequent scroll offsets. No private Ionic APIs. */
16
+ export const readScrollPlacement = (element, owner, id) => {
17
+ const ancestors = [element, ...projectionAncestors(element)];
18
+ const index = ancestors.indexOf(owner);
19
+ if (index <= 0 || owner === element.ownerDocument.scrollingElement)
20
+ return;
21
+ const win = element.ownerDocument.defaultView;
22
+ if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetLeft || win.visualViewport.offsetTop))
23
+ return;
24
+ let left = 0, top = 0, right = win.innerWidth, bottom = win.innerHeight;
25
+ for (const [i, ancestor] of ancestors.entries()) {
26
+ const style = getComputedStyle(ancestor);
27
+ if ((style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity) ||
28
+ (style.zoom && style.zoom !== 'normal' && Number(style.zoom) !== 1) ||
29
+ [style.translate, style.rotate, style.scale, style.perspective].some((value) => value && value !== 'none') ||
30
+ (style.writingMode && style.writingMode !== 'horizontal-tb') ||
31
+ (i < index && /^(fixed|sticky)$/.test(style.position)))
32
+ return;
33
+ if (i === 0)
34
+ continue;
35
+ if (style.scrollbarGutter && style.scrollbarGutter !== 'auto')
36
+ return;
37
+ const clipsX = /^(auto|scroll|hidden|clip)$/.test(style.overflowX);
38
+ const clipsY = /^(auto|scroll|hidden|clip)$/.test(style.overflowY);
39
+ // Nested moving/clipping coordinates require a separate binding; do not guess.
40
+ if (i < index && (clipsX || clipsY))
41
+ return;
42
+ const documentScroll = ancestor === element.ownerDocument.scrollingElement;
43
+ if (i > index &&
44
+ (((clipsX || documentScroll) && ancestor.scrollWidth > ancestor.clientWidth) ||
45
+ ((clipsY || documentScroll) && ancestor.scrollHeight > ancestor.clientHeight)))
46
+ return;
47
+ if ((clipsX || clipsY) &&
48
+ [style.borderTopLeftRadius, style.borderTopRightRadius, style.borderBottomLeftRadius, style.borderBottomRightRadius].some((radius) => parseFloat(radius) > 0))
49
+ return;
50
+ if ((style.clipPath && style.clipPath !== 'none') || (style.maskImage && style.maskImage !== 'none'))
51
+ return;
52
+ if (i >= index) {
53
+ const rect = viewportFrame(ancestor);
54
+ if (clipsX) {
55
+ left = Math.max(left, rect.x);
56
+ right = Math.min(right, rect.x + rect.width);
57
+ }
58
+ if (clipsY) {
59
+ top = Math.max(top, rect.y);
60
+ bottom = Math.min(bottom, rect.y + rect.height);
61
+ }
62
+ }
63
+ }
64
+ const style = getComputedStyle(owner);
65
+ if (!/^(auto|scroll)$/.test(style.overflowX) && !/^(auto|scroll)$/.test(style.overflowY))
66
+ return;
67
+ const viewport = viewportFrame(owner);
68
+ if (right <= left || bottom <= top || !viewport.width || !viewport.height)
69
+ return;
70
+ const source = frame(element.getBoundingClientRect());
71
+ return {
72
+ id: id(owner),
73
+ offsetX: owner.scrollLeft,
74
+ offsetY: owner.scrollTop,
75
+ viewport,
76
+ clip: { x: left, y: top, width: right - left, height: bottom - top },
77
+ contentWidth: owner.scrollWidth,
78
+ contentHeight: owner.scrollHeight,
79
+ frame: {
80
+ ...source,
81
+ x: source.x - viewport.x + owner.scrollLeft,
82
+ y: source.y - viewport.y + owner.scrollTop,
83
+ },
84
+ };
85
+ };
@@ -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
+ `;
@@ -0,0 +1,88 @@
1
+ import type { WebProjectionScope } from './web-projection-scope.js';
2
+ /** All wire coordinates are CSS pixels, relative to the Web viewport. */
3
+ export interface Frame {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface ScrollPlacement {
10
+ id: string;
11
+ reuse?: boolean;
12
+ offsetX: number;
13
+ offsetY: number;
14
+ viewport: Frame;
15
+ clip: Frame;
16
+ contentWidth: number;
17
+ contentHeight: number;
18
+ frame: Frame;
19
+ }
20
+ /** A source and its native rendering. Kind/placement are opaque layout identity keys. */
21
+ export interface WebProjectionSource {
22
+ element: HTMLElement;
23
+ sources?: HTMLElement[];
24
+ scrollElement?: HTMLElement;
25
+ control: Frame & {
26
+ id: string;
27
+ kind?: string;
28
+ placement?: string;
29
+ dark?: boolean;
30
+ rtl?: boolean;
31
+ scroll?: ScrollPlacement;
32
+ };
33
+ }
34
+ /** Multiple fixed fallback elements sharing one Native capture/cover/restore transaction. */
35
+ export interface ScopedWebProjectionSource extends WebProjectionSource {
36
+ scope: WebProjectionScope;
37
+ }
38
+ export interface PreparedWebProjection {
39
+ token: string;
40
+ parts: (Frame & {
41
+ ids: string[];
42
+ url: string;
43
+ })[];
44
+ }
45
+ /** Each action carries only the fields required by that phase of handoff. */
46
+ export type WebProjectionRequest = {
47
+ action: 'prepare';
48
+ revision: number;
49
+ viewportWidth: number;
50
+ ids: string[];
51
+ } | {
52
+ action: 'cover';
53
+ token: string;
54
+ transient?: boolean;
55
+ } | {
56
+ action: 'restore';
57
+ token: string;
58
+ viewportWidth: number;
59
+ parts: (Frame & {
60
+ ids: string[];
61
+ })[];
62
+ } | {
63
+ action: 'prepared' | 'painted' | 'restored' | 'release';
64
+ token: string;
65
+ } | {
66
+ action: 'reset';
67
+ };
68
+ /** Empty prepare results mean capture is unavailable; a partial capture is never valid. */
69
+ export type WebProjectionResult<Request extends WebProjectionRequest> = Request extends {
70
+ action: 'prepare';
71
+ } ? PreparedWebProjection | {
72
+ token?: never;
73
+ parts?: never;
74
+ } : Request extends {
75
+ action: 'cover' | 'restore';
76
+ } ? {
77
+ shown: boolean;
78
+ } : void;
79
+ export interface ListenerHandle {
80
+ remove(): Promise<void> | void;
81
+ }
82
+ /** Implement with the owning plugin's existing ordered transport. No engine plugin registration. */
83
+ export interface WebProjectionTransport {
84
+ send<Request extends WebProjectionRequest>(request: Request): Promise<WebProjectionResult<Request>>;
85
+ listen(listener: (event: {
86
+ active: boolean;
87
+ }) => void): Promise<ListenerHandle>;
88
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /** Current CSS visibility without the Engine's projection hiding rules.
2
+ * Use when an existing renderer decides whether its DOM source is authored hidden.
3
+ * Projection attributes are restored synchronously before returning; this performs
4
+ * a style read and may notify MutationObservers, so it is not a cached measurement.
5
+ */
6
+ export declare const getUnprojectedVisibility: (element: HTMLElement) => string;
@@ -0,0 +1,29 @@
1
+ import { projectionAncestors } from './geometry.js';
2
+ /** Current CSS visibility without the Engine's projection hiding rules.
3
+ * Use when an existing renderer decides whether its DOM source is authored hidden.
4
+ * Projection attributes are restored synchronously before returning; this performs
5
+ * a style read and may notify MutationObservers, so it is not a cached measurement.
6
+ */
7
+ export const getUnprojectedVisibility = (element) => {
8
+ const win = element.ownerDocument.defaultView;
9
+ if (!win)
10
+ return 'hidden';
11
+ const markers = [];
12
+ for (const node of [element, ...projectionAncestors(element)]) {
13
+ for (const attribute of Array.from(node.attributes)) {
14
+ if (attribute.name === 'data-native-ui-snapshot-source' ||
15
+ attribute.name === 'data-native-ui-snapshot-prepared' ||
16
+ attribute.name.startsWith('data-native-ui-session-'))
17
+ markers.push({ node, name: attribute.name, value: attribute.value });
18
+ }
19
+ }
20
+ try {
21
+ for (const { node, name } of markers)
22
+ node.removeAttribute(name);
23
+ return win.getComputedStyle(element).visibility;
24
+ }
25
+ finally {
26
+ for (const { node, name, value } of markers)
27
+ node.setAttribute(name, value);
28
+ }
29
+ };
@@ -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
+ };