@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Masahiko Sakakibara
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# WebView Native UI Engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Bring Web UI above the native layer.**
|
|
4
|
+
|
|
5
|
+
_Visually, through seamless native-to-Web projection._
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
_Conceptual view. Native appearance is projected into the Web layer during overlay presentation._
|
|
10
|
+
|
|
11
|
+
Build WebView apps where native controls and Web content share the same scrolling screens, transitions and overlays.
|
|
12
|
+
|
|
13
|
+
## Beyond the WebView boundary
|
|
14
|
+
|
|
15
|
+
WebView apps combine the Web's flexible layouts with access to native platform capabilities. But native UI and the Web page live in separate rendering layers. A native control placed above the WebView does not automatically participate in DOM scrolling, clipping or stacking order. A Web modal cannot simply cover it with a higher `z-index`.
|
|
16
|
+
|
|
17
|
+
Crossing that boundary takes more than a bridge call. It requires coordinating placement, appearance, input, rendering and restoration across two rendering systems on each platform. Rebuilding that coordination for every component makes otherwise useful designs expensive to deliver and maintain.
|
|
18
|
+
|
|
19
|
+
WebView Native UI Engine makes that work reusable. It lets applications combine native UI with Web-driven screens and interactions, while sharing the machinery that moves presentation between the two layers. The goal is to expand what a WebView app can be built to do—and make that practical to ship.
|
|
20
|
+
|
|
21
|
+
## What this makes possible
|
|
22
|
+
|
|
23
|
+
- **Native UI within Web-designed screens.** Combine native toolbars, tab bars, player controls and eligible SDK views with the application's DOM layout and navigation.
|
|
24
|
+
- **Web overlays that coexist with native content.** Present modals and other overlays over native surfaces, then return to the interactive native UI when they close.
|
|
25
|
+
- **Scrolling and transitions that retain native appearance.** Carry the native surface's captured appearance through Web-layer movement, without recreating its styling in CSS.
|
|
26
|
+
- **Shared infrastructure across components and platforms.** Reuse the same projection and restoration lifecycle on iOS and Android instead of implementing it independently in every integration.
|
|
27
|
+
|
|
28
|
+
## The approach
|
|
29
|
+
|
|
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
|
+
|
|
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.
|
|
33
|
+
|
|
34
|
+
## Integration
|
|
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.
|
|
37
|
+
|
|
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.
|
|
40
|
+
|
|
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.
|
|
42
|
+
|
|
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+.
|
|
44
|
+
|
|
45
|
+
## Further reading
|
|
46
|
+
|
|
47
|
+
- [Projection behavior and advanced integration](docs/projection.md) — DOM requirements, lifecycle, fallback policies, optional Ionic support and lower-level APIs.
|
|
48
|
+
- [Development and releases](docs/maintenance.md) — repository structure, validation and publishing.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
[MIT](LICENSE). Commercial use, modification and redistribution are permitted with the license's notice and attribution requirements. The Engine's license does not change the licenses of applications or plugins that depend on it.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
3
|
+
Pod::Spec.new do |s|
|
|
4
|
+
s.name = 'WebViewNativeUIEngine'
|
|
5
|
+
s.version = package['version']
|
|
6
|
+
s.summary = 'Native UI infrastructure for Capacitor and Cordova plugin maintainers.'
|
|
7
|
+
s.homepage = 'https://github.com/webview-native-ui/engine'
|
|
8
|
+
s.license = { :type => 'MIT', :file => 'LICENSE' }
|
|
9
|
+
s.preserve_paths = 'LICENSE'
|
|
10
|
+
s.author = { 'rdlabo' => 'sakakibara@rdlabo.jp' }
|
|
11
|
+
s.source = { :git => 'https://github.com/webview-native-ui/engine.git', :tag => "v#{s.version}" }
|
|
12
|
+
s.resource_bundles = { 'WebViewNativeUIEngine' => ['ios/Sources/WebViewNativeUIEngine/Resources/*.js'] }
|
|
13
|
+
s.source_files = 'ios/Sources/WebViewNativeUIEngine/**/*.swift'
|
|
14
|
+
s.ios.deployment_target = '15.0'
|
|
15
|
+
s.swift_version = '5.9'
|
|
16
|
+
s.frameworks = 'UIKit', 'WebKit'
|
|
17
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function* projectionAncestors(element) {
|
|
2
|
+
let current = element;
|
|
3
|
+
while (current) {
|
|
4
|
+
const root = current.getRootNode();
|
|
5
|
+
current = current.assignedSlot ?? current.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
|
|
6
|
+
if (current)
|
|
7
|
+
yield current;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const fitsClip = (element) => {
|
|
11
|
+
const rect = element.getBoundingClientRect();
|
|
12
|
+
for (const ancestor of projectionAncestors(element)) {
|
|
13
|
+
const style = getComputedStyle(ancestor);
|
|
14
|
+
if (style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity)
|
|
15
|
+
return false;
|
|
16
|
+
const bounds = ancestor.getBoundingClientRect();
|
|
17
|
+
const left = bounds.left + ancestor.clientLeft;
|
|
18
|
+
const top = bounds.top + ancestor.clientTop;
|
|
19
|
+
if (/^(hidden|clip|scroll|auto)$/.test(style.overflowX) && (rect.left < left - 1 || rect.right > left + ancestor.clientWidth + 1))
|
|
20
|
+
return false;
|
|
21
|
+
if (/^(hidden|clip|scroll|auto)$/.test(style.overflowY) && (rect.top < top - 1 || rect.bottom > top + ancestor.clientHeight + 1))
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Shared by npm consumers and the runtime injected from the native library. */
|
|
2
|
+
export type OverlayPhase = 'willPresent' | 'didPresent' | 'willDismiss' | 'didDismiss' | 'change';
|
|
3
|
+
export interface OverlayChange {
|
|
4
|
+
element?: HTMLElement;
|
|
5
|
+
phase: OverlayPhase;
|
|
6
|
+
event?: Event;
|
|
7
|
+
}
|
|
8
|
+
export interface OverlayDefinition {
|
|
9
|
+
selector: string;
|
|
10
|
+
events: Partial<Record<OverlayPhase, string>>;
|
|
11
|
+
}
|
|
12
|
+
export interface OverlayOptions {
|
|
13
|
+
/** Additional overlay components; Ionic defaults remain enabled. */
|
|
14
|
+
additional?: readonly OverlayDefinition[];
|
|
15
|
+
exclude?: string;
|
|
16
|
+
/** Return undefined to use the normal DOM/backdrop relationship. */
|
|
17
|
+
covers?: (overlay: HTMLElement, target: HTMLElement) => boolean | undefined;
|
|
18
|
+
}
|
|
19
|
+
export interface OverlayElement extends HTMLElement {
|
|
20
|
+
presented?: boolean;
|
|
21
|
+
overlayIndex?: number;
|
|
22
|
+
showBackdrop?: boolean;
|
|
23
|
+
initialBreakpoint?: number;
|
|
24
|
+
backdropBreakpoint?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Whether a Web overlay covers the underlying surface, independently of its renderer. */
|
|
27
|
+
export declare const usesOverlayBackdrop: (element: HTMLElement, breakpoint?: number) => boolean;
|
|
28
|
+
declare const createCoordinator: (doc: Document) => {
|
|
29
|
+
readonly active: ReadonlySet<HTMLElement>;
|
|
30
|
+
subscribe(listener: (change: OverlayChange) => void): () => void;
|
|
31
|
+
/** Renderer ownership is explicit because a child native window is outside the DOM stack. */
|
|
32
|
+
setNative(element: HTMLElement, value: boolean): void;
|
|
33
|
+
isExcluded(element: HTMLElement): boolean;
|
|
34
|
+
isNative(element: HTMLElement): boolean;
|
|
35
|
+
readonly stack: HTMLElement[];
|
|
36
|
+
isCovered(target: HTMLElement): boolean;
|
|
37
|
+
enable(defaults: readonly OverlayDefinition[], config?: OverlayOptions): void;
|
|
38
|
+
};
|
|
39
|
+
export type OverlayCoordinator = ReturnType<typeof createCoordinator>;
|
|
40
|
+
/** Access is inert: only the explicit framework initializer installs DOM listeners. */
|
|
41
|
+
export declare const getOverlayCoordinator: (doc: Document) => OverlayCoordinator;
|
|
42
|
+
export {};
|
|
@@ -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,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,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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './dom/geometry.js';
|
|
3
|
+
export * from './dom/scroll-placement.js';
|
|
4
|
+
export { createWebProjectionScope, type WebProjectionScope } from './projection/scope.js';
|
|
5
|
+
export * from './projection/web.js';
|
|
6
|
+
export * from './dom/scroll-bindings.js';
|
|
7
|
+
export type { ProjectionEvents, ProjectionDestination } from './projection/events.js';
|
|
8
|
+
export { getOverlayCoordinator, usesOverlayBackdrop, type OverlayChange, type OverlayOptions } from './dom/overlays.js';
|
|
9
|
+
export { getUnprojectedVisibility } from './dom/visibility.js';
|
|
10
|
+
export { bindProjectionPlacement, type ProjectionPlacementBinding } from './projection/placement.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './dom/geometry.js';
|
|
3
|
+
export * from './dom/scroll-placement.js';
|
|
4
|
+
export { createWebProjectionScope } from './projection/scope.js';
|
|
5
|
+
export * from './projection/web.js';
|
|
6
|
+
export * from './dom/scroll-bindings.js';
|
|
7
|
+
export { getOverlayCoordinator, usesOverlayBackdrop } from './dom/overlays.js';
|
|
8
|
+
export { getUnprojectedVisibility } from './dom/visibility.js';
|
|
9
|
+
export { bindProjectionPlacement } from './projection/placement.js';
|
package/dist/ionic.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type OverlayDefinition, type OverlayOptions } from './dom/overlays.js';
|
|
2
|
+
export type { OverlayOptions as IonicOverlayOptions } from './dom/overlays.js';
|
|
3
|
+
/** Call once at app startup. Repeated default calls share the existing configuration and monitor. */
|
|
4
|
+
export declare const enableSupportIonicOverlays: (options?: OverlayOptions, doc?: Document | undefined) => {
|
|
5
|
+
readonly active: ReadonlySet<HTMLElement>;
|
|
6
|
+
subscribe(listener: (change: import("./dom/overlays.js").OverlayChange) => void): () => void;
|
|
7
|
+
setNative(element: HTMLElement, value: boolean): void;
|
|
8
|
+
isExcluded(element: HTMLElement): boolean;
|
|
9
|
+
isNative(element: HTMLElement): boolean;
|
|
10
|
+
readonly stack: HTMLElement[];
|
|
11
|
+
isCovered(target: HTMLElement): boolean;
|
|
12
|
+
enable(defaults: readonly OverlayDefinition[], config?: OverlayOptions): void;
|
|
13
|
+
} | undefined;
|
|
14
|
+
/** The background surface shares the same order used for registered DOM projections. */
|
|
15
|
+
export declare const ionicModalStack: (doc: Document) => HTMLElement[];
|
|
16
|
+
export declare const coveredIonicSurface: (overlay: HTMLElement) => HTMLElement | undefined;
|
package/dist/ionic.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getOverlayCoordinator } from './dom/overlays.js';
|
|
2
|
+
const defaults = [
|
|
3
|
+
...['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => ({
|
|
4
|
+
selector: `ion-${name.replace(/[A-Z]/g, (letter, index) => (index ? '-' : '') + letter.toLowerCase())}`,
|
|
5
|
+
events: {
|
|
6
|
+
willPresent: `ion${name}WillPresent`,
|
|
7
|
+
didPresent: `ion${name}DidPresent`,
|
|
8
|
+
willDismiss: `ion${name}WillDismiss`,
|
|
9
|
+
didDismiss: `ion${name}DidDismiss`,
|
|
10
|
+
},
|
|
11
|
+
})),
|
|
12
|
+
{ selector: 'ion-modal', events: { change: 'ionBreakpointDidChange' } },
|
|
13
|
+
{
|
|
14
|
+
selector: 'ion-menu',
|
|
15
|
+
events: { willPresent: 'ionWillOpen', didPresent: 'ionDidOpen', willDismiss: 'ionWillClose', didDismiss: 'ionDidClose' },
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
/** Call once at app startup. Repeated default calls share the existing configuration and monitor. */
|
|
19
|
+
export const enableSupportIonicOverlays = (options, doc = globalThis.document) => {
|
|
20
|
+
if (!doc)
|
|
21
|
+
return;
|
|
22
|
+
const coordinator = getOverlayCoordinator(doc);
|
|
23
|
+
coordinator.enable(defaults, options);
|
|
24
|
+
return coordinator;
|
|
25
|
+
};
|
|
26
|
+
/** The background surface shares the same order used for registered DOM projections. */
|
|
27
|
+
export const ionicModalStack = (doc) => getOverlayCoordinator(doc).stack.filter((element) => element.matches('ion-modal'));
|
|
28
|
+
export const coveredIonicSurface = (overlay) => {
|
|
29
|
+
const stack = ionicModalStack(overlay.ownerDocument);
|
|
30
|
+
return overlay.matches('ion-modal') ? stack[stack.indexOf(overlay) - 1] : stack[stack.length - 1];
|
|
31
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type NativeSessionConfiguration } from './session.js';
|
|
2
|
+
/** Cordova plugins forward their existing notifications through NativeUIProjection.receive. */
|
|
3
|
+
export declare const notify: (id: string, name: string, data: unknown) => void | undefined;
|
|
4
|
+
export declare const receive: (id: string, key: string | null, value: any, error?: string) => void;
|
|
5
|
+
export declare const show: (id: string) => void;
|
|
6
|
+
export declare const hide: (id: string) => boolean;
|
|
7
|
+
export declare const stop: (id: string) => Promise<void>;
|
|
8
|
+
export declare const start: (config: NativeSessionConfiguration) => Promise<void>;
|