@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,311 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// One fixed DOM fallback and its native view. The plugin retains this registration.
5
+ /// Existing notifyListeners events drive projection; no public plugin method is intercepted.
6
+ public final class NativeUIProjection: NSObject, WKScriptMessageHandler {
7
+ public enum Runtime: String { case capacitor, cordova }
8
+ public enum FailedFallback: String { case web, native }
9
+
10
+ public struct ReceiveListeners {
11
+ public struct Projection {
12
+ public let native: [String]
13
+ public let web: [String]
14
+ public let none: [String]
15
+ public init(native: [String] = [], web: [String] = [], none: [String] = []) {
16
+ self.native = native; self.web = web; self.none = none
17
+ }
18
+ }
19
+ public let update: [String]
20
+ public let projection: Projection
21
+ public let match: [String: Any]
22
+ public init(update: [String] = [], projection: Projection = .init(), match: [String: Any] = [:]) {
23
+ self.update = update; self.projection = projection; self.match = match
24
+ }
25
+ fileprivate var configuration: [String: Any] {
26
+ ["update": update, "projection": ["native": projection.native, "web": projection.web, "none": projection.none], "match": match]
27
+ }
28
+ }
29
+ private weak var webView: WKWebView?
30
+ private let views: () -> [String: UIView]
31
+ private let snapshots: NativeUICoveredSnapshots
32
+ private let scrolling = NativeUIScrollBindings()
33
+ private var masks: [String: CAShapeLayer] = [:]
34
+ private let id = "nativeUI" + UUID().uuidString.replacingOccurrences(of: "-", with: "")
35
+ private let origins: Set<String>
36
+ private let allowedOriginsJSON: String
37
+ private let canCapture: () -> Bool
38
+ private let runtime: Runtime
39
+ private let receiveListeners: ReceiveListeners
40
+ private let notificationLock = NSLock()
41
+ private var notificationDestination: String?
42
+ private var recordedDestination: String? {
43
+ notificationLock.lock(); defer { notificationLock.unlock() }
44
+ return notificationDestination
45
+ }
46
+ private var script: WKUserScript?
47
+ private var documentID: String?
48
+ private var destination = "native"
49
+ private var connected = false
50
+ private var restoreAccepted = true
51
+ private var hiddenByProjection: [UIView] = []
52
+ private var visible = true
53
+ private var closed = false
54
+ private struct Anchor { let native: CGRect; let image: CGRect }
55
+ private var anchors: [String: [String: Anchor]] = [:]
56
+ private var captured: [String: [String: UIView]] = [:]
57
+
58
+ public convenience init(webView: WKWebView?, plugin: String, projectionSelector: String,
59
+ receiveListeners: ReceiveListeners = .init(),
60
+ allowedOrigins: Set<String> = ["capacitor://localhost", "app://localhost", "https://localhost", "http://localhost"],
61
+ failedFallback: FailedFallback = .web, runtime: Runtime = .capacitor,
62
+ canCapture: @escaping () -> Bool = { true }, view: @escaping () -> UIView?) throws {
63
+ try self.init(webView: webView, plugin: plugin, projectionSelector: projectionSelector,
64
+ receiveListeners: receiveListeners, allowedOrigins: allowedOrigins, failedFallback: failedFallback, runtime: runtime,
65
+ canCapture: canCapture, snapshots: NativeUICoveredSnapshots(), view: view)
66
+ }
67
+ init(webView: WKWebView?, plugin: String, projectionSelector: String,
68
+ receiveListeners: ReceiveListeners = .init(),
69
+ allowedOrigins: Set<String> = ["capacitor://localhost", "app://localhost", "https://localhost", "http://localhost"],
70
+ failedFallback: FailedFallback = .web, runtime: Runtime = .capacitor,
71
+ canCapture: @escaping () -> Bool = { true }, snapshots: NativeUICoveredSnapshots,
72
+ view: @escaping () -> UIView?) throws {
73
+ self.allowedOriginsJSON = String(data: try JSONSerialization.data(withJSONObject: Array(allowedOrigins)), encoding: .utf8)!
74
+ self.runtime = runtime
75
+ self.snapshots = snapshots
76
+ self.receiveListeners = receiveListeners
77
+ guard let webView else { throw ProjectionError.missingWebView }
78
+ self.webView = webView; self.views = { view().map { ["view": $0] } ?? [:] }; self.origins = allowedOrigins; self.canCapture = canCapture
79
+ super.init()
80
+ precondition(Thread.isMainThread)
81
+ guard !projectionSelector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ProjectionError.invalidConfiguration }
82
+ let config: [String: Any] = ["id": id, "plugin": plugin, "projectionSelector": projectionSelector,
83
+ "receiveListeners": receiveListeners.configuration, "failedFallback": failedFallback.rawValue, "runtime": runtime.rawValue]
84
+ let json = String(data: try JSONSerialization.data(withJSONObject: config), encoding: .utf8)!
85
+ let allowed = allowedOriginsJSON
86
+ let runtime = try Self.runtime()
87
+ let source = "if (!globalThis.WebViewNativeUIEngineRuntime) { \(runtime) } WebViewNativeUIEngineRuntime.start(\(json)).catch(console.error);"
88
+ let script = WKUserScript(source: "if (\(allowed).includes(location.origin)) { \(source) }", injectionTime: .atDocumentEnd, forMainFrameOnly: true)
89
+ self.script = script
90
+ webView.configuration.userContentController.add(WeakProjectionHandler(self), name: id)
91
+ webView.configuration.userContentController.addUserScript(script)
92
+ evaluate(source)
93
+ }
94
+ /// Observe the plugin's existing notification before forwarding it to Capacitor.
95
+ /// Cordova also forwards notifications to the Engine session, never to application listeners.
96
+ public func receive(_ eventName: String, data: [String: Any]? = nil) {
97
+ guard receiveListeners.match.allSatisfy({ key, expected in
98
+ guard let actual = data?[key] else { return false }
99
+ if let a = actual as? NSNumber, let b = expected as? NSNumber {
100
+ return CFGetTypeID(a) == CFGetTypeID(b) && a == b
101
+ }
102
+ return (actual as? String).map { $0 == expected as? String } ?? false
103
+ }) else { return }
104
+ let routes = receiveListeners.projection
105
+ let matches = [("native", routes.native), ("web", routes.web), ("none", routes.none)]
106
+ .filter { $0.1.contains(eventName) }
107
+ if matches.count == 1 {
108
+ notificationLock.lock()
109
+ notificationDestination = matches[0].0
110
+ notificationLock.unlock()
111
+ }
112
+ guard runtime == .cordova, !matches.isEmpty || receiveListeners.update.contains(eventName),
113
+ let json = try? JSONSerialization.data(withJSONObject: [id, eventName, data ?? [:]]),
114
+ let args = String(data: json, encoding: .utf8) else { return }
115
+ DispatchQueue.main.async { [weak self] in
116
+ guard let self, !self.closed else { return }
117
+ self.evaluate("globalThis.WebViewNativeUIEngineRuntime?.notify(...\(args))")
118
+ }
119
+ }
120
+ // Check in the executing document: a native URL check can race navigation.
121
+ private func evaluate(_ source: String) {
122
+ webView?.evaluateJavaScript("if (\(allowedOriginsJSON).includes(location.origin)) { \(source) }", completionHandler: nil)
123
+ }
124
+ private enum ProjectionError: Error { case missingWebView, invalidConfiguration, missingRuntime }
125
+ private static func runtime() throws -> String {
126
+ #if SWIFT_PACKAGE
127
+ let bundle = Bundle.module
128
+ #else
129
+ let containing = Bundle(for: NativeUIProjection.self)
130
+ let bundle = containing.url(forResource: "WebViewNativeUIEngine", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? containing
131
+ #endif
132
+ guard let url = bundle.url(forResource: "native-ui-engine", withExtension: "js") else { throw ProjectionError.missingRuntime }
133
+ return try String(contentsOf: url, encoding: .utf8)
134
+ }
135
+ public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
136
+ let origin = message.frameInfo.securityOrigin
137
+ let defaultPort = origin.port == 0 || (origin.protocol == "https" && origin.port == 443) || (origin.protocol == "http" && origin.port == 80)
138
+ guard origins.contains("\(origin.protocol)://\(origin.host)" + (defaultPort ? "" : ":\(origin.port)")),
139
+ !closed, message.frameInfo.isMainFrame, let webView,
140
+ let text = message.body as? String, let data = text.data(using: .utf8),
141
+ let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
142
+ let key = message["key"] as? String, key.hasPrefix(id + ":"),
143
+ let request = message["request"] as? [String: Any] else { return }
144
+ let page = key.split(separator: ":").dropFirst().first.map(String.init)
145
+ if documentID != page { snapshots.reset(); scrolling.reset(); captured.removeAll(); anchors.removeAll(); documentID = page }
146
+ let complete: ([String: Any]) -> Void = { [weak self] value in
147
+ guard let self, !self.closed, self.documentID == page else { return }
148
+ self.reply(key, value)
149
+ }
150
+ let token = request["token"] as? String ?? ""
151
+ switch request["action"] as? String {
152
+ case "connect":
153
+ if !connected { destination = (views().values.contains(where: { !$0.isHidden }) ? "native" : "none"); connected = true }
154
+ complete(["destination": recordedDestination ?? destination])
155
+ case "destination":
156
+ if let next = request["destination"] as? String, ["native", "web", "none"].contains(next) { destination = next; connected = true }
157
+ complete([:])
158
+ case "track":
159
+ scrolling.reset()
160
+ for (id, mask) in masks { if views()[id]?.layer.mask === mask { views()[id]?.layer.mask = nil } }
161
+ masks.removeAll()
162
+ guard let raw = request["scroll"] as? [String: Any], let width = request["viewportWidth"] as? Double, width.isFinite, width > 0,
163
+ let data = try? JSONSerialization.data(withJSONObject: raw),
164
+ let placement = try? JSONDecoder().decode(NativeUIScrollPlacement.self, from: data), placement.isValid else {
165
+ complete(["tracked": false]); return
166
+ }
167
+ let scale = webView.bounds.width / width
168
+ let current = views()
169
+ guard !current.isEmpty, current.values.allSatisfy({ $0.layer.mask == nil }) else { complete(["tracked": false]); return }
170
+ let nodes = current.map { id, view -> NativeUIScrollNode in
171
+ let frame = view.convert(view.bounds, to: webView)
172
+ return NativeUIScrollNode(id: id, scroll: NativeUIScrollPlacement(id: placement.id,
173
+ offsetX: placement.offsetX, offsetY: placement.offsetY, viewport: placement.viewport, clip: placement.clip,
174
+ contentWidth: placement.contentWidth, contentHeight: placement.contentHeight,
175
+ frame: NativeUIFrame(x: frame.minX/scale-placement.viewport.x+placement.offsetX,
176
+ y: frame.minY/scale-placement.viewport.y+placement.offsetY, width: frame.width/scale, height: frame.height/scale)))
177
+ }
178
+ let rejected = scrolling.replace(nodes, in: webView, scale: scale) { [weak self, weak webView] id, frame, clip in
179
+ guard let self, let webView, self.visible, !self.snapshots.hidesNativeControls,
180
+ let view = self.views()[id], current[id] === view, let parent = view.superview else { return }
181
+ let old = view.convert(view.bounds, to: webView)
182
+ let from = parent.convert(CGPoint(x: old.midX, y: old.midY), from: webView)
183
+ let to = parent.convert(CGPoint(x: frame.midX, y: frame.midY), from: webView)
184
+ view.transform.tx += to.x-from.x; view.transform.ty += to.y-from.y
185
+ let mask = self.masks[id] ?? CAShapeLayer()
186
+ self.masks[id] = mask
187
+ mask.frame = view.bounds
188
+ mask.path = UIBezierPath(rect: clip.isNull ? .zero : view.convert(clip, from: webView).intersection(view.bounds)).cgPath
189
+ view.layer.mask = mask
190
+ }
191
+ complete(["tracked": rejected.isEmpty])
192
+ case "prepare":
193
+ guard visible, canCapture(), let ids = request["ids"] as? [String], let width = request["viewportWidth"] as? Double, width.isFinite, width > 0,
194
+ let window = webView.window else { complete([:]); return }
195
+ let selected = views().filter { ids.contains($0.key) }
196
+ guard selected.count == ids.count else { complete([:]); return }
197
+ snapshots.observe(window) { [weak self] active in self?.reply(nil, ["active": active]) }
198
+ snapshots.prepare(frames: { [weak self, weak webView] in
199
+ guard let self, let webView, self.visible, self.canCapture() else { return [:] }
200
+ let current = self.views()
201
+ guard selected.allSatisfy({ current[$0.key] === $0.value && !$0.value.isHidden && $0.value.window != nil }) else { return [:] }
202
+ return selected.mapValues { $0.convert($0.bounds, to: webView) }
203
+ }, in: webView, viewportWidth: width) { [weak self] result in
204
+ if let self, let token = result["token"] as? String, let parts = result["parts"] as? [[String: Any]] {
205
+ self.captured[token] = selected
206
+ let scale = webView.bounds.width / width
207
+ var entries: [String: Anchor] = [:]
208
+ for part in parts {
209
+ guard let ids = part["ids"] as? [String], let x = part["x"] as? CGFloat, let y = part["y"] as? CGFloat,
210
+ let w = part["width"] as? CGFloat, let h = part["height"] as? CGFloat else { continue }
211
+ let image = CGRect(x: x*scale, y: y*scale, width: w*scale, height: h*scale)
212
+ for id in ids { if let view = selected[id] { entries[id] = Anchor(native: view.convert(view.bounds, to: webView), image: image) } }
213
+ }
214
+ self.anchors[token] = entries
215
+ }
216
+ complete(result)
217
+ }
218
+ case "cover":
219
+ let selected = captured[token] ?? [:], current = views()
220
+ let valid = visible && canCapture() && !selected.isEmpty && selected.allSatisfy { current[$0.key] === $0.value }
221
+ restoreAccepted = true
222
+ complete(["shown": valid && snapshots.cover(token, in: webView, views: Array(Dictionary(grouping: selected.values, by: { ObjectIdentifier($0) }).values.compactMap { $0.first }), transient: true)])
223
+ case "restore":
224
+ restoreAccepted = false
225
+ guard let width = request["viewportWidth"] as? Double, width.isFinite, width > 0,
226
+ let parts = request["parts"] as? [[String: Any]] else { complete(["shown": false]); return }
227
+ var frames: [String: CGRect] = [:]
228
+ let scale = webView.bounds.width / width
229
+ for part in parts {
230
+ guard let ids = part["ids"] as? [String], let x = part["x"] as? Double, let y = part["y"] as? Double,
231
+ let w = part["width"] as? Double, let h = part["height"] as? Double,
232
+ [x,y,w,h].allSatisfy({ $0.isFinite }), w > 0, h > 0 else { complete(["shown": false]); return }
233
+ for id in ids { frames[id] = CGRect(x: x*scale, y: y*scale, width: w*scale, height: h*scale) }
234
+ }
235
+ guard Set(frames.keys) == snapshots.identifiers(for: token), !frames.isEmpty else { complete(["shown": false]); return }
236
+ let current = views()
237
+ // Preserve renderer-owned constraints. Translation follows the corresponding DOM's movement.
238
+ for (id, frame) in frames {
239
+ guard let view = current[id], captured[token]?[id] === view, let anchor = anchors[token]?[id] else { complete(["shown": false]); return }
240
+ let old = view.convert(view.bounds, to: webView)
241
+ guard abs(old.width-anchor.native.width) < 0.1, abs(old.height-anchor.native.height) < 0.1,
242
+ abs(frame.width-anchor.image.width) < 0.1, abs(frame.height-anchor.image.height) < 0.1 else { complete(["shown": false]); return }
243
+ }
244
+ for (id, frame) in frames {
245
+ guard let view = current[id], let parent = view.superview, let anchor = anchors[token]?[id] else { continue }
246
+ let old = view.convert(view.bounds, to: webView)
247
+ let from = parent.convert(CGPoint(x: old.midX, y: old.midY), from: webView)
248
+ let to = parent.convert(CGPoint(x: anchor.native.midX+frame.minX-anchor.image.minX,
249
+ y: anchor.native.midY+frame.minY-anchor.image.minY), from: webView)
250
+ view.transform.tx += to.x-from.x; view.transform.ty += to.y-from.y
251
+ }
252
+ restoreAccepted = snapshots.restore(in: webView, token: token, frames: frames)
253
+ complete(["shown": restoreAccepted])
254
+ case "resume":
255
+ guard restoreAccepted else { reply(key, [:], error: "Native UI geometry changed"); return }
256
+ snapshots.restoreControls(); snapshots.finishCovering()
257
+ webView.window?.layoutIfNeeded()
258
+ CATransaction.begin()
259
+ CATransaction.setCompletionBlock { complete([:]) }
260
+ views().values.forEach { $0.setNeedsDisplay() }
261
+ CATransaction.commit()
262
+ case "visibility":
263
+ visible = request["visible"] as? Bool ?? false
264
+ snapshots.restoreControls()
265
+ if visible {
266
+ hiddenByProjection.forEach { $0.isHidden = false }; hiddenByProjection.removeAll()
267
+ } else {
268
+ for view in views().values where !view.isHidden {
269
+ hiddenByProjection.append(view); view.isHidden = true
270
+ }
271
+ }
272
+ complete([:])
273
+ case "painted": snapshots.retireCopy(token); complete([:])
274
+ case "restored": snapshots.retireCopy(token, entry: false); complete([:])
275
+ case "release": snapshots.release(token); captured.removeValue(forKey: token); anchors.removeValue(forKey: token); complete([:])
276
+ case "reset": snapshots.reset(); captured.removeAll(); anchors.removeAll(); complete([:])
277
+ default: complete([:])
278
+ }
279
+ }
280
+ private func reply(_ key: String?, _ value: [String: Any], error: String? = nil) {
281
+ guard !closed else { return }
282
+ let values: [Any] = [id, key as Any? ?? NSNull(), value, error as Any? ?? NSNull()]
283
+ guard let data = try? JSONSerialization.data(withJSONObject: values), let json = String(data: data, encoding: .utf8) else { return }
284
+ evaluate("globalThis.WebViewNativeUIEngineRuntime?.receive(...\(json))")
285
+ }
286
+ /// Call with the plugin's teardown. Does not destroy the renderer's views or DOM.
287
+ deinit { destroy() }
288
+ public func destroy() {
289
+ guard !closed else { return }
290
+ closed = true
291
+ evaluate("globalThis.WebViewNativeUIEngineRuntime?.stop('\(id)')")
292
+ if let controller = webView?.configuration.userContentController {
293
+ controller.removeScriptMessageHandler(forName: id)
294
+ let remaining = controller.userScripts.filter { $0 !== script }
295
+ controller.removeAllUserScripts()
296
+ remaining.forEach { controller.addUserScript($0) }
297
+ }
298
+ snapshots.reset(); scrolling.reset(); captured.removeAll(); anchors.removeAll()
299
+ if destination != "none" { hiddenByProjection.forEach { $0.isHidden = false } }
300
+ hiddenByProjection.removeAll()
301
+ for (id, mask) in masks { if views()[id]?.layer.mask === mask { views()[id]?.layer.mask = nil } }
302
+ masks.removeAll()
303
+ }
304
+ }
305
+ private final class WeakProjectionHandler: NSObject, WKScriptMessageHandler {
306
+ weak var owner: NativeUIProjection?
307
+ init(_ owner: NativeUIProjection) { self.owner = owner }
308
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
309
+ owner?.userContentController(controller, didReceive: message)
310
+ }
311
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";var WebViewNativeUIEngineRuntime=(()=>{var Ie=Object.defineProperty;var rt=Object.getOwnPropertyDescriptor;var it=Object.getOwnPropertyNames;var st=Object.prototype.hasOwnProperty;var at=(e,o)=>{for(var n in o)Ie(e,n,{get:o[n],enumerable:!0})},ct=(e,o,n,l)=>{if(o&&typeof o=="object"||typeof o=="function")for(let d of it(o))!st.call(e,d)&&d!==n&&Ie(e,d,{get:()=>o[d],enumerable:!(l=rt(o,d))||l.enumerable});return e};var lt=e=>ct(Ie({},"__esModule",{value:!0}),e);var jt={};at(jt,{hide:()=>Tt,notify:()=>gt,receive:()=>Et,show:()=>Pt,start:()=>St,stop:()=>Lt});var dt=Symbol.for("webview-native-ui-engine.projection-placement");var Fe=e=>e?.[dt];var ut=(e,o)=>{if(e.matches("ion-toast, ion-menu"))return!1;let n=e;if(n.showBackdrop===!1)return!1;if(!e.matches("ion-modal"))return!0;let l=o??n.initialBreakpoint;return l===void 0||l>(n.backdropBreakpoint??0)},pt=Symbol.for("@webview-native-ui/engine/overlays/v1"),ft=e=>{let o=new Set,n=new Set,l=new Set,d=new WeakMap,r=new WeakSet,T=new Map,h=[],v={},P=new Map,S,x=p=>{for(let f of[...l])try{f(p)}catch(a){console.error("Native UI overlay listener failed",a)}},C=(p,f,a)=>{f==="willPresent"||f==="didPresent"?(r.delete(p),o.add(p)):f==="didDismiss"&&(r.add(p),o.delete(p),n.delete(p),d.delete(p));let E=a?.detail?.breakpoint;typeof E=="number"&&E>0&&d.set(p,E),x({element:p,phase:f,event:a})},U=(p=!1)=>{let f=!1;for(let a of o)(!a.isConnected||a.ownerDocument!==e||!h.some(({selector:E})=>a.matches(E)))&&(o.delete(a),n.delete(a),f=!0);for(let a of e.querySelectorAll(h.map(({selector:E})=>E).join(",")))!o.has(a)&&!r.has(a)&&(p||!a.matches("ion-popover"))&&(a.presented||a.classList.contains("show-menu"))&&(o.add(a),f=!0);f&&x({phase:"change"})};return{get active(){return o},subscribe(p){return l.add(p),()=>{l.delete(p)}},setNative(p,f){n.has(p)!==f&&(f?n.add(p):n.delete(p),x({element:p,phase:"change"}))},isExcluded(p){return!!v.exclude&&p.matches(v.exclude)},isNative(p){return n.has(p)},get stack(){let p=[...o].filter(a=>a.isConnected&&a.ownerDocument===e),f=a=>{let E=Number.parseFloat(e.defaultView.getComputedStyle(a).zIndex);return Number.isFinite(E)?E:a.overlayIndex??0};return p.sort((a,E)=>f(a)-f(E)),p},isCovered(p){let f=this.stack,a=f.reduce((E,F,m)=>F.contains(p)?m:E,-1);return f.some((E,F)=>{if(n.has(E)||v.exclude&&E.matches(v.exclude))return!1;let m=v.covers?.(E,p);return m!==void 0?m:F>a&&!E.contains(p)&&(E.matches("ion-menu")||ut(E,d.get(E)))})},enable(p,f){let a={...v,...f},E=[...p,...a.additional??[]];a.exclude&&e.querySelector(a.exclude);let F=new Map;for(let k of E){e.querySelector(k.selector);for(let[D,R]of Object.entries(k.events)){let $=F.get(R);if($&&$.phase!==D)throw new TypeError(`Overlay event "${R}" has conflicting phases`);F.set(R,{phase:D,selector:$?`${$.selector}, ${k.selector}`:k.selector})}}v=a,h=E,P=F;for(let k of P.keys()){if(T.has(k))continue;let D=R=>{let $=P.get(k),W=R.composedPath().find(oe=>oe instanceof e.defaultView.HTMLElement&&oe.matches($.selector));W&&C(W,$.phase,R)};e.addEventListener(k,D,!0),T.set(k,()=>e.removeEventListener(k,D,!0))}for(let[k,D]of T)P.has(k)||(D(),T.delete(k));let m=!S;S||(S=new MutationObserver(()=>U()),S.observe(e.documentElement,{childList:!0,subtree:!0}),e.addEventListener("nativeUIOverlayAdopted",k=>{let D=k.detail;D?.ownerDocument===e&&C(D,"willPresent",k)})),U(m),(m||f)&&x({phase:"change"})}}},qe=e=>{let o=e;return o[pt]??=ft(e)};function*le(e){let o=e;for(;o;){let n=o.getRootNode();o=o.assignedSlot??o.parentElement??(n instanceof ShadowRoot?n.host:null),o&&(yield o)}}var mt=({x:e,y:o,width:n,height:l})=>({x:e,y:o,width:n,height:l}),_e=e=>{let o=e.getBoundingClientRect(),n=getComputedStyle(e),l=parseFloat(n.borderLeftWidth)||0,d=parseFloat(n.borderTopWidth)||0;return{x:o.left+l,y:o.top+d,width:o.width-l-(parseFloat(n.borderRightWidth)||0),height:o.height-d-(parseFloat(n.borderBottomWidth)||0)}},ze=(e,o,n)=>{let l=[e,...le(e)],d=l.indexOf(o);if(d<=0||o===e.ownerDocument.scrollingElement)return;let r=e.ownerDocument.defaultView;if(r.visualViewport&&(r.visualViewport.scale!==1||r.visualViewport.offsetLeft||r.visualViewport.offsetTop))return;let T=0,h=0,v=r.innerWidth,P=r.innerHeight;for(let[U,p]of l.entries()){let f=getComputedStyle(p);if(f.transform!=="none"&&!new DOMMatrixReadOnly(f.transform).isIdentity||f.zoom&&f.zoom!=="normal"&&Number(f.zoom)!==1||[f.translate,f.rotate,f.scale,f.perspective].some(m=>m&&m!=="none")||f.writingMode&&f.writingMode!=="horizontal-tb"||U<d&&/^(fixed|sticky)$/.test(f.position))return;if(U===0)continue;if(f.scrollbarGutter&&f.scrollbarGutter!=="auto")return;let a=/^(auto|scroll|hidden|clip)$/.test(f.overflowX),E=/^(auto|scroll|hidden|clip)$/.test(f.overflowY);if(U<d&&(a||E))return;let F=p===e.ownerDocument.scrollingElement;if(U>d&&((a||F)&&p.scrollWidth>p.clientWidth||(E||F)&&p.scrollHeight>p.clientHeight)||(a||E)&&[f.borderTopLeftRadius,f.borderTopRightRadius,f.borderBottomLeftRadius,f.borderBottomRightRadius].some(m=>parseFloat(m)>0)||f.clipPath&&f.clipPath!=="none"||f.maskImage&&f.maskImage!=="none")return;if(U>=d){let m=_e(p);a&&(T=Math.max(T,m.x),v=Math.min(v,m.x+m.width)),E&&(h=Math.max(h,m.y),P=Math.min(P,m.y+m.height))}}let S=getComputedStyle(o);if(!/^(auto|scroll)$/.test(S.overflowX)&&!/^(auto|scroll)$/.test(S.overflowY))return;let x=_e(o);if(v<=T||P<=h||!x.width||!x.height)return;let C=mt(e.getBoundingClientRect());return{id:n(o),offsetX:o.scrollLeft,offsetY:o.scrollTop,viewport:x,clip:{x:T,y:h,width:v-T,height:P-h},contentWidth:o.scrollWidth,contentHeight:o.scrollHeight,frame:{...C,x:C.x-x.x+o.scrollLeft,y:C.y-x.y+o.scrollTop}}};var Ke=e=>{let o=globalThis;if(o.Capacitor?.convertFileSrc)return o.Capacitor.convertFileSrc(e);if(!e.startsWith("file:"))return e;if(o.WkWebView?.convertFilePath)return o.WkWebView.convertFilePath(e);throw new Error("Native UI file URL requires a host resource handler")};var de=e=>new Promise((o,n)=>{let l=setTimeout(()=>n(new Error("Native UI bridge timed out")),5e3);e.then(o,n).finally(()=>clearTimeout(l))}),$e=async e=>{try{return await de(e)}catch(o){throw e.then(n=>n.remove()).catch(()=>{}),o}};var Ne=async(e,o)=>{let n=new Map;for(let h of e.update??[])n.set(h,{updated:!0});for(let h of["native","web","none"])for(let v of e.projection?.[h]??[]){let P=n.get(v)??{updated:!1};if(P.destination&&P.destination!==h)throw new TypeError(`Projection event "${v}" has conflicting destinations`);n.set(v,{...P,destination:h})}let l=!1,d=[],r=async()=>{l=!0;let v=(await Promise.allSettled(d.splice(0).map(P=>Promise.resolve().then(()=>P.remove())))).find(P=>P.status==="rejected");if(v?.status==="rejected")throw v.reason},T=[...n].map(async([h,v])=>{let P=await $e(Promise.resolve().then(()=>e.addListener(h,S=>{!l&&(!e.accepts||e.accepts(S))&&o(v.destination,v.updated)})));l?await P.remove():d.push(P)});try{return await Promise.all(T),{remove:r}}catch(h){throw await r().catch(()=>{}),h}};var Ge=`
2
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]),
3
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]) *,
4
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::after,
5
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::part(native) { visibility: hidden !important; }
6
+ [data-native-ui-snapshot][data-native-ui-snapshot]::before {
7
+ all:initial!important;content:""!important;display:block!important;position:absolute!important;
8
+ visibility:visible!important;opacity:0!important;will-change:opacity!important;
9
+ pointer-events:none!important;image-rendering:pixelated!important;
10
+ }
11
+ [data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }
12
+ [data-native-ui-snapshot-visible][data-native-ui-snapshot][data-native-ui-snapshot]::before { opacity:1!important; }
13
+ `;var vt=(e,o=!1)=>JSON.stringify(e.map(({control:n})=>[n.id,n.kind,n.placement,o?0:n.x,o?0:n.y,n.width,n.height,n.dark,n.rtl])),Je=(e,o,n=o.getComputedStyle(e))=>{let l=e.getBoundingClientRect(),d=(S,x)=>{let C=parseFloat(S);return n.display!=="inline"&&Number.isFinite(C)?C+(n.boxSizing==="border-box"?0:x.reduce((U,p)=>U+(parseFloat(n.getPropertyValue(p))||0),0)):NaN},r=d(n.width,["padding-left","padding-right","border-left-width","border-right-width"]),T=d(n.height,["padding-top","padding-bottom","border-top-width","border-bottom-width"]),h=1;if(!(r>0&&T>0))for(let S of[e,...le(e)]){let x=(S===e?n:o.getComputedStyle(S)).getPropertyValue("zoom");h*=(parseFloat(x)||1)/(x.endsWith("%")?100:1)}let v=r>0?l.width/r:h,P=T>0?l.height/T:h;return{x:l.x,y:l.y,scaleX:v||1,scaleY:P||1,left:(parseFloat(n.borderLeftWidth)||0)-e.scrollLeft,top:(parseFloat(n.borderTopWidth)||0)-e.scrollTop,position:n.position}},ht=async(e,o,n)=>{let l="native",d=e.element.ownerDocument,r=d.defaultView,T=Array.from(r.crypto.getRandomValues(new Uint32Array(4)),t=>t.toString(16)).join("-"),h=t=>t.isConnected&&t.ownerDocument===d,v=t=>!!t?.isConnected&&t.ownerDocument===d,P=o.send.bind(o),S="data-native-ui-snapshot",x=`${e.namespace}:sources-change`,C=new Set,U=new Set,p=new Set,f=new WeakMap,a=0,E=t=>JSON.stringify(t.map(c=>(f.has(c.scope)||f.set(c.scope,++a),[f.get(c.scope),vt([c],!!n.opaqueUnderlay)]))),F=new Map,m=t=>{let c=t.getRootNode();return c.nodeType===11&&"host"in c?c:d},k=t=>{let c=F.get(t);if(!c){let g=d.createElement("style"),i=d.createElement("style");g.setAttribute("data-native-ui-snapshot-base",""),i.setAttribute(S,""),g.textContent=Ge,(t.nodeType===11?t:t.head).append(g,i),c={base:g,image:i},F.set(t,c)}return c.image},D=()=>`${r.innerWidth}:${r.innerHeight}:${r.devicePixelRatio}`,R=!1,$=!1,W=0,oe=0,O,u,ie=!1,B=!1,ae=0,ge=0,K=0,N=0,se=0,X=-1,Z=!1,we=!1,q,ce=new Set,ke=(t,c,g)=>{let i=()=>{ce.delete(i)&&P({action:t,token:c}).catch(()=>{})};ce.add(i),g&&!Z?r.requestAnimationFrame(()=>r.requestAnimationFrame(i)):i()},V=new Set,be=()=>{V.forEach(t=>p.delete(t)),V.clear()},fe=(t,c=!1)=>{!t||t===O||t===u||(t.images.forEach(g=>{g.src=""}),ke("release",t.data.token,c))},Q=t=>t.filter(c=>{if(!v(c.scope.element)||![c.element,...c.sources??[]].every(h))return!1;if(c.control.scroll){let g=c.element.getBoundingClientRect(),i=c.control.scroll.clip;if(g.right<=i.x||g.bottom<=i.y||g.left>=i.x+i.width||g.top>=i.y+i.height)return!1}return n.eligible?.(c)??!0}),We=async()=>{if(N=0,Z||l!=="native"||we||ie||u||n.blocked()||!q||O?.revision===q.revision&&X===se)return;let{revision:t,candidates:c}=q,g=K,i=se,L=D(),j=Q(c);if(!j.length)return;we=!0;let H,_=!1,te=[];try{let ne=!1,he=P({action:"prepare",revision:n.captureRevision?.()??t,viewportWidth:r.innerWidth,ids:j.map(w=>w.control.id)});he.then(w=>{ne&&w.token&&P({action:"release",token:w.token}).catch(()=>{})}).catch(()=>{});try{H=await de(he)}finally{ne=!0}if(!H.token||!H.parts?.length)return;let ye=new Set(j.map(w=>w.control.id));if(H.parts.some(w=>!w.url.startsWith("file:")&&!/^\/__native_ui_engine__\/nativeUI[a-fA-F0-9]+\/[a-fA-F0-9-]+-[0-9]+\.png$/.test(w.url)||!w.ids.length||w.ids.some(z=>!ye.has(z))||![w.x,w.y,w.width,w.height].every(Number.isFinite)||w.width<=0||w.height<=0)||new Set(H.parts.flatMap(w=>w.ids)).size!==ye.size||H.parts.some(w=>new Set(w.ids.map(z=>j.find(Be=>Be.control.id===z).scope)).size!==1)||(await de(Promise.all(H.parts.map(async w=>{let z=d.createElement("img");return te.push(z),z.src=Ke(w.url),await z.decode(),z}))),Z||l!=="native"||ie||u||n.blocked()||K!==g||D()!==L||q?.revision!==t||j.some(w=>!v(w.scope.element)||![w.element,...w.sources??[]].every(h))))return;let Le=O;O={data:H,images:te,sources:Array.from(new Set(j.flatMap(w=>[w.element,...w.sources??[]]))),targets:H.parts.map(w=>j.find(z=>w.ids.includes(z.control.id)).element),scopes:H.parts.map(w=>j.find(z=>w.ids.includes(z.control.id)).scope),parents:H.parts.map(w=>j.find(z=>w.ids.includes(z.control.id)).scope.element.parentNode),frames:[],viewport:L,revision:t,layout:E(j)},be(),O.sources.forEach(w=>{p.add(w),V.add(w)}),Re(),n.opaqueUnderlay&&P({action:"prepared",token:H.token}).catch(()=>{}),r.dispatchEvent(new Event(x)),X=i,_=!0,fe(Le)}catch{}finally{_||te.forEach(ne=>{ne.src=""}),H?.token&&!_&&P({action:"release",token:H.token}).catch(()=>{}),we=!1,q&&(q.revision!==t||se!==i)&&G()}},G=()=>{r.clearTimeout(N),!Z&&l==="native"&&!u&&!ie&&!n.blocked()&&(N=r.setTimeout(()=>{We()},50))},Ee,Se=(t,c)=>{if(c.some(i=>i.scope.element.ownerDocument!==d||[i.element,...i.sources??[]].some(L=>L.ownerDocument!==d)))throw new TypeError("All projection sources and fallback scopes must share one Document");if(n.blocked())return;!u&&O&&O.layout!==E(Q(c))&&ve();let g=E(c);Ee!==g&&(Ee=g,se++,K++),q?.revision!==t&&K++,q={revision:t,candidates:c},(O?.revision!==t||X!==se)&&G()},je=t=>{!u&&O&&t&&q?.candidates.some(c=>c.scrollElement===t&&O.sources.includes(c.element))&&ve(),u&&(Me(),Oe()),se++,K++,G()},me=await $e(o.listen(({active:t})=>{ie=t,K++,r.clearTimeout(N),t||G()})),Ce=new Set,Pe=t=>{Ce.delete(t)&&t.removeAttribute("data-native-ui-snapshot-prepared"),t.removeAttribute("data-native-ui-snapshot-visible"),t.removeAttribute(S),t.removeAttribute("data-native-ui-snapshot-source")},Te=()=>{oe++,r.cancelAnimationFrame(W),r.cancelAnimationFrame(Y),Y=0,J.clear(),W=0,R=!1,$=!1,be(),u?.sources.forEach(t=>{C.delete(t),U.delete(t)}),A.forEach(Pe),A.clear(),F.forEach(({base:t,image:c})=>{t.remove(),c.remove()}),F.clear()},s=(t=u,c=B)=>{if(u!==t)return;let g=new Set([u,O]);Te(),u=void 0,O=void 0,B=!1,r.clearTimeout(ae),g.forEach(i=>fe(i,c)),n.wake()},y=()=>{if(!u)return;if(O!==u){s();return}let t=u;Te(),u=void 0,B=!1,r.clearTimeout(ae),ke("restored",t.data.token,!0),t.sources.forEach(c=>{p.add(c),V.add(c)}),Re(),n.wake()},b=()=>{B&&y(),ce.forEach(t=>t())},I=t=>t.scopes.map(c=>v(c.element)?c.element:void 0),A=new Set,ee=new Map,J=new Map,Y=0,Me=()=>{for(let[t,c]of J){if(!t.isConnected)continue;let g=r.getComputedStyle(t),i=`${c.x-(parseFloat(g.borderLeftWidth)||0)+t.scrollLeft}px`,L=`${c.y-(parseFloat(g.borderTopWidth)||0)+t.scrollTop}px`;c.style.left!==i&&c.style.setProperty("left",i,"important"),c.style.top!==L&&c.style.setProperty("top",L,"important")}},Oe=()=>{if(Y||!u)return;[...J.keys()].some(c=>c.getAnimations?.().some(g=>g.playState==="running"&&g.effect?.getKeyframes().some(i=>["borderLeftWidth","borderTopWidth","borderWidth"].some(L=>L in i))))&&(Y=r.requestAnimationFrame(()=>{Y=0,Me(),Oe()}))},Re=()=>{let t=u??O;if(!t||B)return;if(!t.sources.every(h)||I(t).some(i=>!i)){ve();return}let c=I(t);if(u){if(c.some(L=>!L?.isConnected||!F.has(m(L)))){s(u,!1);return}let i=new Set;for(let[L,j]of t.targets.entries()){let H=ee.get(j),_=c[L];!H||!_?.isConnected||(_.setAttribute(S,H),_.setAttribute("data-native-ui-snapshot-source",""),_.toggleAttribute("data-native-ui-snapshot-visible",$),i.add(_))}for(let L of A)i.has(L)||Pe(L);A.clear(),i.forEach(L=>A.add(L));return}ee.clear(),J.clear(),A.forEach(Pe),A.clear();let g=new Map;for(let i of new Set(c)){if(!i)continue;A.add(i);let L=k(m(i)),j=g.get(L);j||(j={rules:[],positions:[]},g.set(L,j));let{rules:H,positions:_}=j;i!==t.targets[c.indexOf(i)]&&!i.hasAttribute("data-native-ui-snapshot-prepared")&&(i.setAttribute("data-native-ui-snapshot-prepared",""),Ce.add(i)),i.toggleAttribute("data-native-ui-snapshot-visible",!!u||!!n.opaqueUnderlay),u&&i.setAttribute("data-native-ui-snapshot-source","");let te=c.flatMap((M,re)=>M===i?[re]:[]),ne=r.getComputedStyle(i);{let M=Je(i,r,ne);te.forEach(re=>{let De=t.data.parts[re];n.opaqueUnderlay&&t.frames[re]||(t.frames[re]={x:n.opaqueUnderlay?0:(De.x-M.x)/M.scaleX,y:n.opaqueUnderlay?0:(De.y-M.y)/M.scaleY,width:De.width/M.scaleX,height:De.height/M.scaleY})})}let he=te.map(M=>t.frames[M]),ye=Math.min(...he.map(M=>M.x)),Le=Math.min(...he.map(M=>M.y)),w=Math.max(...he.map(M=>M.x+M.width)),z=Math.max(...he.map(M=>M.y+M.height)),Be=(parseFloat(ne.borderLeftWidth)||0)-i.scrollLeft,nt=(parseFloat(ne.borderTopWidth)||0)-i.scrollTop,Ae=`${T}-${te[0]}`;te.forEach(M=>ee.set(t.targets[M],Ae)),i.setAttribute(S,Ae),H.push(`[${S}="${Ae}"][data-native-ui-snapshot-visible] { ${ne.position==="static"?"position:relative!important;":""} }`);let ot=te.map(M=>{let re=t.frames[M];return`url(${JSON.stringify(t.images[M].src)}) ${re.x-ye}px ${re.y-Le}px / ${re.width}px ${re.height}px no-repeat`}),xe=d.createElement("span").style;xe.cssText=`left:${ye-Be}px;top:${Le-nt}px;width:${w-ye}px;height:${z-Le}px;background:${ot.join(",")};`;for(let M of Array.from(xe))xe.setProperty(M,xe.getPropertyValue(M),"important");H.push(`[${S}="${Ae}"][${S}][${S}]::before { ${xe.cssText} }`),_.push({target:i,x:ye,y:Le})}t.parents=t.scopes.map(i=>i.element.parentNode);for(let[i,{rules:L,positions:j}]of g)i.textContent=L.join(`
14
+ `),j.forEach(({target:H,x:_,y:te},ne)=>J.set(H,{x:_,y:te,style:i.sheet.cssRules[ne*2+1].style}))},Ve=()=>{if(b(),Z||l==="none"||u)return;let t=O;if(!t||t.viewport!==D()||!t.sources.every(h)||n.current&&!n.current(t.targets)||t.targets.some(i=>!ee.has(i))||I(t).some(i=>!i?.isConnected)||[...A].some(i=>!i.isConnected))return;t.scopes.some((i,L)=>i.element.parentNode!==t.parents[L])&&Re(),Me();let c=++oe,g=()=>oe===c;R=!0,$=!!n.opaqueUnderlay,u=t,K++,r.clearTimeout(N),t.sources.forEach(i=>{C.add(i)}),A.forEach(i=>{i.setAttribute("data-native-ui-snapshot-source",""),i.toggleAttribute("data-native-ui-snapshot-visible",$)}),W=r.requestAnimationFrame(()=>{W=0,g()&&(R=!1,n.wake())}),de(P({action:"cover",token:t.data.token,...n.transient?{transient:!0}:{}})).then(i=>{if(g()){if(R=!1,i?.shown!==!0){s(t,!1);return}B||($=!0,t.sources.forEach(L=>U.add(L)),Re(),Me(),Oe()),r.cancelAnimationFrame(W),W=r.requestAnimationFrame(()=>{W=0,g()&&P({action:"painted",token:t.data.token}).catch(()=>{g()&&s(t,!1)})}),n.wake()}}).catch(()=>{g()&&s(t,!1)})},Xe=()=>{if(!(!u||Z||B)){if(!u.sources.every(h)||I(u).some(t=>!t)||n.current&&!n.current(u.targets)){s(u,!1);return}if(!n.blocked()){if(n.transient){let t=u,c=I(t);if(c.some(i=>!i?.isConnected)){s(t,!1);return}let g=c.map((i,L)=>{let j=Je(i,r),H=t.frames[L];return{...t.data.parts[L],x:j.x+H.x*j.scaleX,y:j.y+H.y*j.scaleY,width:H.width*j.scaleX,height:H.height*j.scaleY}});g.some((i,L)=>["x","y","width","height"].some(j=>i[j]!==t.data.parts[L][j]))&&(n.opaqueUnderlay?t.data={...t.data,parts:g}:O=void 0),P({action:"restore",token:t.data.token,viewportWidth:r.innerWidth,parts:g}).catch(()=>{})}ge=n.revision?.()??q?.revision??0,B=!0,u.sources.forEach(t=>C.delete(t)),ae=r.setTimeout(()=>{B&&s(u,!1)},250),n.wake()}}},tt=(t,c,g)=>{!B||!u||g===void 0||g<=ge||!n.transient&&t||u.data.parts.every(i=>i.ids.every(L=>c?.includes(L)))&&(y(),G())},ve=()=>{ce.forEach(c=>c()),K++,r.clearTimeout(N);let t=O;O=void 0,fe(t),u?s(u,!1):(Te(),ee.clear(),r.dispatchEvent(new Event(x)))},Ye;try{n.events&&(Ye=await Ne(n.events,(t,c)=>{if(!Z){if(t&&(l=t),l==="none"){ve();return}t==="web"&&Ve(),t==="native"&&(Xe(),G()),c&&je()}}))}catch(t){throw Z=!0,ve(),await de(Promise.resolve(me.remove())).catch(()=>{}),t}return{get owners(){return[...C]},get visibleOwners(){return[...U]},get preparedOwners(){return[...p]},consider:Se,changed:je,moved:()=>{Me(),Oe()},get token(){return B?void 0:u?.data.token},begin:Ve,get restoring(){return B},get handoffPending(){return R},retireRestoration:b,end:Xe,updated:tt,invalidate:ve,async destroy(){Z=!0,ve(),await Promise.allSettled([de(Promise.resolve().then(()=>me?.remove())),de(Promise.resolve().then(()=>Ye?.remove()))])}}},Ze=async(e,o,n)=>{let l=await ht(e,o,n),d=l.consider;return Object.assign(l,{consider:(r,T)=>d(r,T.map(h=>({...h,scope:e})))})};var Qe=(e,o)=>{if(e.nodeType!==1||e.namespaceURI!=="http://www.w3.org/1999/xhtml"||!e.ownerDocument||!("style"in e))throw new TypeError("A Web projection scope requires an HTMLElement fallback");if(e.matches("img, input, textarea, select, video, audio, canvas, iframe, embed, object, br, hr, area, base, link, meta, source, track, wbr"))throw new TypeError("A Web projection fallback must support ::before; use its existing container element");return Object.freeze({element:e,namespace:o})};var et=async(e,o,n={addListener:async(l,d)=>globalThis.Capacitor.addListener(e.plugin,l,d)})=>{let l="view",d=e.receiveListeners,r=!1,T=!1,h="native",v="native",P=qe(document),S=()=>h==="native"&&m&&P.isCovered(m)?"web":h,x=!1,C=!1,U=!1,p,f,a,E=!1,F=()=>{let s=E;E=!0;try{a?.begin()}finally{E=s}},m,k={},D,R=0,$=()=>{R++,D=void 0;let s=Fe(m);s?.owner===k&&(s.projected=!1,s.owner=void 0)},W={listen:s=>o.listen(s),send:s=>{if(!e.placementHandoff)return o.send(s);let y=Fe(m);if(s.action==="cover"){let J=++R;return D=s.token,y&&(y.owner=k,y.projected=!1),o.send(s).then(Y=>(J===R&&y===Fe(m)&&y?.owner===k&&(y.projected=Y?.shown===!0),Y)).catch(Y=>{throw J===R&&y?.owner===k&&($(),y.invalidate()),Y})}let b=s.action==="release"&&s.token===D,I=D&&(s.action==="restore"||b||s.action==="reset"||s.action==="visibility"&&s.visible);I&&R++;let A=R,ee=()=>{A!==R||y?.owner!==k||($(),y.invalidate())};try{let J=I&&y?.owner===k?{...s,token:"token"in s?s.token:D,placement:y.measure()}:s;return o.send(J).then(Y=>(A===R&&(s.action==="resume"||b||s.action==="reset"||s.action==="visibility")&&$(),Y)).catch(Y=>{throw ee(),Y})}catch(J){return ee(),Promise.reject(J)}}},oe=0,O=0,u=0,ie=!1,B=!1,ae=!1,ge=!1,K=document.createElement("style"),N=`data-native-ui-session-${e.id.replace(/[^a-zA-Z0-9-]/g,"")}`;K.textContent=`[${N}="native"], [${N}="native"] *, [${N}="none"], [${N}="none"] * {visibility:hidden!important}`,document.documentElement.append(K);let se=()=>{let s=document.querySelectorAll(e.projectionSelector);return s.length===1&&s[0]instanceof HTMLElement?s[0]:void 0},X=()=>{m&&(v==="web"||v==="native"&&C?m.removeAttribute(N):m.setAttribute(N,v))},Z=()=>{if(!m?.isConnected)return[];let s=m.getBoundingClientRect(),y={x:s.x,y:s.y,width:s.width,height:s.height},b=p&&ze(m,p,()=>e.id);return[{element:m,scrollElement:p,control:{id:l,...y,...b?{scroll:b}:{}}}]},we=async()=>{if(r)return;let s=u,y=e.failedFallback==="native"&&S()==="native"&&!!m?.isConnected;await W.send({action:"visibility",visible:y}).catch(()=>{}),!(r||s!==u)&&(y||v==="none"?X():m?.removeAttribute(N))},q=()=>{if(!x||v!=="native"||!m?.isConnected||a?.token||a?.restoring)return;let s=e.failedFallback==="native";W.send({action:"visibility",visible:s}).catch(()=>{}),s?X():m.removeAttribute(N)},ce=()=>{if(r||E)return;if(q(),v==="web"&&a&&!a.token&&!a.restoring){W.send({action:"visibility",visible:!1}).catch(()=>{}),m?.removeAttribute(N);return}if(ie||!a?.restoring||v!=="native")return;ie=!0;let s=u,y=a;W.send({action:"resume"}).then(()=>{r||s!==u||y!==a||(C=!1,y.updated(void 0,[l],++oe),X(),V())}).catch(()=>{s!==u||r||(y.invalidate(),we())}).finally(()=>{ie=!1,ce()})},ke=async()=>{if(r||B||x)return;if(a?.restoring){ce();return}let s=se();if(s!==m){if(u++,$(),m?.removeAttribute(N),await a?.destroy(),a=void 0,r)return;m=s,C=!1,fe.disconnect(),m&&fe.observe(m)}if(Ee(),v=S(),!m){await W.send({action:"visibility",visible:!1});return}if(!a){B=!0;let y=u;try{let b=await Ze(Qe(m,e.id),W,{wake:ce,blocked:()=>v!=="native"||x||C&&!a?.token,revision:()=>oe,captureRevision:e.captureMode==="view"?()=>O:void 0,transient:!0,opaqueUnderlay:e.captureMode==="view"});if(r||y!==u){await b.destroy();return}a=b}finally{B=!1}}if(!r){if(v==="native"&&C){await W.send({action:"visibility",visible:!1}),X();return}if(v==="native"){let y=u;if(await W.send({action:"visibility",visible:!0}),r||y!==u||v!=="native")return;X(),p=[...le(m)].find(A=>{let ee=getComputedStyle(A);return/auto|scroll/.test(ee.overflowX+ee.overflowY)&&(A.scrollHeight>A.clientHeight||A.scrollWidth>A.clientWidth)});let b=p&&ze(m,p,()=>e.id),I=await W.send({action:"track",viewportWidth:innerWidth,...b?{scroll:b}:{}});if(r||y!==u)return;U=I?.tracked===!0,a.consider(e.captureMode==="view"?O:++oe,Z())}else await W.send({action:"visibility",visible:!1}),X()}},V=()=>{r||(ae=!0,!(!T||ge)&&(ge=!0,Promise.resolve().then(async()=>{try{for(;ae&&!r;)ae=!1,await ke()}catch{await we()}finally{ge=!1,ae&&V()}})))},be=()=>{a?.invalidate(),V()},fe=new ResizeObserver(be);window.addEventListener("resize",be);let Q=new Set,We=new WeakSet,G=s=>{let y=s.composedPath()[0]??s.target;if(!(r||v!=="native"||!m||We.has(s)||y!==document&&![...le(m)].includes(y))){if(We.add(s),U&&y===p){a?.changed(y);return}x?e.captureMode==="view"?a?.moved():a?.changed():((ie||a?.restoring)&&u++,x=!0,F(),C=e.failedFallback!=="native",a?.token?m.removeAttribute(N):q()),clearTimeout(f),f=setTimeout(()=>{x=!1,!(r||v!=="native")&&(a?.end(),a?.restoring||V())},100)}},Ee=()=>{let s=new Set(m?le(m):[]),y=s.size!==Q.size||[...s].some(b=>!Q.has(b));for(let b of Q)s.has(b)||(b.removeEventListener("scroll",G),b.removeEventListener("slotchange",Se),Q.delete(b));for(let b of s)Q.has(b)||(b.addEventListener("scroll",G,{passive:!0}),b.addEventListener("slotchange",Se),Q.add(b));y&&(U=!1,p=void 0,a?.invalidate(),V())},Se=()=>{Ee(),V()};document.addEventListener("scroll",G,!0);let je=new MutationObserver(s=>{Ee(),se()!==m?V():m&&s.some(y=>y.type==="childList")&&me(S())});je.observe(document.documentElement,{childList:!0,attributes:!0,subtree:!0});let me=(s,y=!1)=>{if(r||!y&&s===v)return;x=!1,clearTimeout(f),v=s,u++;let b=u;v==="none"?W.send({action:"visibility",visible:!1}).then(()=>{v!=="none"||b!==u||r||(a?.invalidate(),X())}).catch(()=>{v!=="none"||b!==u||r||(a?.invalidate(),X())}):v==="web"?(F(),a?.token||W.send({action:"visibility",visible:!1}).catch(()=>{}),X()):v==="native"&&(a?.end(),a?.restoring||V())},Ce=P.subscribe(()=>queueMicrotask(()=>me(S()))),Pe,Te=async()=>{if(!r){r=!0,$(),u++,Ce(),je.disconnect(),fe.disconnect(),window.removeEventListener("resize",be),document.removeEventListener("scroll",G,!0);for(let s of Q)s.removeEventListener("scroll",G),s.removeEventListener("slotchange",Se);Q.clear(),clearTimeout(f),m?.removeAttribute(N),K.remove(),await Promise.allSettled([Promise.resolve(Pe?.remove()),a?.destroy()])}};try{let s=u;Pe=await Ne({addListener:(b,I)=>n.addListener(b,I),update:d?.update,projection:d?.projection,accepts:b=>!d?.match||Object.entries(d?.match).every(([I,A])=>typeof b=="object"&&b!==null&&b[I]===A)},(b,I)=>{r||(b&&(b==="native"&&(C=!1),h=b,W.send({action:"destination",destination:b}).catch(()=>{}),me(S(),!0)),I&&(O++,a?.changed(),v==="native"&&V()))});let y=await W.send({action:"connect"});return u===s&&["native","web","none"].includes(y?.destination)&&(h=y.destination),v=S(),T=!0,V(),{destroy:Te,show:()=>{C=!1,h="native",me(S(),!0)},hide:()=>{h=v="none",u++,x=!1,clearTimeout(f),a?.invalidate(),X()}}}catch(s){throw await Te(),s}};var ue=new Map,pe=new Map,He=new Map,yt=Array.from(crypto.getRandomValues(new Uint32Array(4)),e=>e.toString(16)).join("-"),Ue=new Map,gt=(e,o,n)=>Ue.get(e)?.get(o)?.(n),wt=0,bt=(e,o)=>new Promise((n,l)=>{let d=`${e}:${yt}:${++wt}`,r=setTimeout(()=>{pe.delete(d),l(new Error("Native UI session timed out"))},5e3);pe.set(d,{resolve:n,reject:l,timer:r});let T=JSON.stringify({key:d,request:o}),h=globalThis,v=h.webkit?.messageHandlers?.[e]??h[e]??h.nativeUIEngine;if(!v){clearTimeout(r),pe.delete(d),l(new Error("Native UI session unavailable"));return}try{v.postMessage(T)}catch(P){clearTimeout(r),pe.delete(d),l(P)}}),Et=(e,o,n,l)=>{if(o===null){He.get(e)?.(n);return}let d=pe.get(o);!d||!o.startsWith(`${e}:`)||(pe.delete(o),clearTimeout(d.timer),l?d.reject(new Error(l)):d.resolve(n))},Pt=e=>{ue.get(e)?.show()},Tt=e=>(ue.get(e)?.hide(),!0),Lt=async e=>{let o=ue.get(e);ue.delete(e),Ue.delete(e),He.delete(e);for(let[n,l]of pe)n.startsWith(`${e}:`)&&(pe.delete(n),clearTimeout(l.timer),l.reject(new Error("Native UI session closed")));await o?.destroy()},St=async e=>{if(ue.has(e.id))return;let o,n=!1,l,d={show:()=>{l="native",o?.show()},hide:()=>{l="none",o?.hide()},destroy:async()=>{n=!0,await o?.destroy()}};ue.set(e.id,d);try{if(document.readyState==="loading"&&await new Promise(T=>document.addEventListener("DOMContentLoaded",()=>T(),{once:!0})),n)return;let r=new Map;e.runtime==="cordova"&&Ue.set(e.id,r),o=await et(e,{send:T=>bt(e.id,T),listen:async T=>(He.set(e.id,T),{remove:()=>{He.get(e.id)===T&&He.delete(e.id)}})},e.runtime==="cordova"?{addListener:async(T,h)=>(r.set(T,h),{remove:()=>{r.delete(T)}})}:void 0),l==="none"?o.hide():l==="native"&&o.show(),n&&await o.destroy()}catch(r){throw ue.get(e.id)===d&&(ue.delete(e.id),Ue.delete(e.id)),r}};return lt(jt);})();
@@ -0,0 +1,27 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// One tracker per DOM scroll owner. A lost established mapping is rejected;
5
+ /// the host's normal acknowledgement path can then request a fresh acquisition.
6
+ public final class NativeUIScrollBindings {
7
+ private var trackers: [String: NativeUIScrollTracker] = [:]
8
+ public init() {}
9
+ public func replace(_ nodes: [NativeUIScrollNode], in webView: WKWebView, scale: CGFloat,
10
+ render: @escaping (String, CGRect, CGRect) -> Void) -> [String] {
11
+ let previous = trackers
12
+ trackers.removeAll()
13
+ var rejected: [String] = []
14
+ for (id, group) in Dictionary(grouping: nodes.filter { $0.scroll != nil }, by: { $0.scroll!.id }) {
15
+ if group.contains(where: { $0.scroll?.reuse == true }) {
16
+ if let tracker = previous[id], tracker.replace(group, webView: webView, scale: scale) {
17
+ trackers[id] = tracker
18
+ } else { rejected.append(contentsOf: group.map(\.id)) }
19
+ } else if let tracker = NativeUIScrollTracker(webView: webView, nodes: group, scale: scale, render: render) {
20
+ trackers[id] = tracker
21
+ } else { rejected.append(contentsOf: group.map(\.id)) }
22
+ }
23
+ return rejected
24
+ }
25
+ public func update() { trackers.values.forEach { $0.update() } }
26
+ public func reset() { trackers.removeAll() }
27
+ }
@@ -0,0 +1,117 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ public struct NativeUIScrollPlacement: Decodable, Equatable {
5
+ public let id: String
6
+ public let reuse: Bool?
7
+ public let offsetX: Double
8
+ public let offsetY: Double
9
+ public let viewport: NativeUIFrame
10
+ public let clip: NativeUIFrame
11
+ public let contentWidth: Double
12
+ public let contentHeight: Double
13
+ public let frame: NativeUIFrame
14
+
15
+ public init(id: String, reuse: Bool? = nil, offsetX: Double, offsetY: Double,
16
+ viewport: NativeUIFrame, clip: NativeUIFrame, contentWidth: Double, contentHeight: Double, frame: NativeUIFrame) {
17
+ self.id = id; self.reuse = reuse; self.offsetX = offsetX; self.offsetY = offsetY
18
+ self.viewport = viewport; self.clip = clip; self.contentWidth = contentWidth; self.contentHeight = contentHeight; self.frame = frame
19
+ }
20
+
21
+ public var isValid: Bool {
22
+ !id.isEmpty && viewport.isValid && clip.isValid && frame.isValid &&
23
+ offsetX.isFinite && offsetY.isFinite && contentWidth.isFinite && contentHeight.isFinite && contentWidth > 0 && contentHeight > 0
24
+ }
25
+ }
26
+
27
+ /// One observer per overflow container. Web measures layout; native owns scrolling.
28
+ /// WKWebView's overflow hierarchy is not a public DOM mapping: only bind a unique match.
29
+ public final class NativeUIScrollTracker {
30
+ private weak var webView: WKWebView?
31
+ private weak var scrollView: UIScrollView?
32
+ private let placement: NativeUIScrollPlacement
33
+ private let scale: CGFloat
34
+ private let contentOrigin: CGPoint
35
+ private let viewportCorrection: CGPoint
36
+ private var frames: [String: CGRect] = [:]
37
+ private let render: (String, CGRect, CGRect) -> Void
38
+ private var observation: NSKeyValueObservation?
39
+
40
+ public init?(webView: WKWebView, nodes: [NativeUIScrollNode], scale: CGFloat,
41
+ render: @escaping (String, CGRect, CGRect) -> Void) {
42
+ guard let placement = nodes.first?.scroll, placement.isValid,
43
+ nodes.allSatisfy({ $0.scroll?.id == placement.id && $0.scroll?.viewport == placement.viewport &&
44
+ $0.scroll?.clip == placement.clip && $0.scroll?.contentWidth == placement.contentWidth &&
45
+ $0.scroll?.contentHeight == placement.contentHeight }), scale.isFinite, scale > 0 else { return nil }
46
+ let transform = CGAffineTransform(scaleX: scale, y: scale)
47
+ let viewport = placement.viewport.rect.applying(transform)
48
+ let candidates = Self.scrollViews(in: webView).filter { scroll in
49
+ let frame = scroll.convert(scroll.bounds, to: webView)
50
+ return scroll !== webView.scrollView && !scroll.isHidden && scroll.window != nil &&
51
+ abs(scroll.zoomScale - 1) < 0.001 &&
52
+ abs(frame.minX - viewport.minX) < 2 && abs(frame.minY - viewport.minY) < 2 &&
53
+ abs(frame.width - viewport.width) < 2 && abs(frame.height - viewport.height) < 2 &&
54
+ abs(scroll.contentSize.width - placement.contentWidth * scale) < 3 &&
55
+ abs(scroll.contentSize.height - placement.contentHeight * scale) < 3
56
+ }
57
+ guard candidates.count == 1, let scroll = candidates.first, !scroll.isDragging, !scroll.isDecelerating else { return nil }
58
+ self.webView = webView
59
+ self.scrollView = scroll
60
+ self.placement = placement
61
+ self.scale = scale
62
+ // Calibrate coordinate origins once while acquiring a resting scroller.
63
+ // CSS RTL scroll origins and UIKit dimensions can differ by rounded pixels.
64
+ // Keep this mapping for content updates during motion; never recalibrate per tick.
65
+ self.contentOrigin = CGPoint(x: scroll.contentOffset.x - placement.offsetX * scale,
66
+ y: scroll.contentOffset.y - placement.offsetY * scale)
67
+ let actual = scroll.convert(scroll.bounds, to: webView)
68
+ self.viewportCorrection = CGPoint(x: viewport.minX - actual.minX, y: viewport.minY - actual.minY)
69
+ self.render = render
70
+ setFrames(nodes)
71
+ observation = scroll.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in self?.update() }
72
+ }
73
+
74
+ /// Reuse the observer and coordinate origin when only source content/layout changes.
75
+ public func replace(_ nodes: [NativeUIScrollNode], webView: WKWebView, scale: CGFloat) -> Bool {
76
+ guard self.webView === webView, let scrollView, scrollView.isDescendant(of: webView),
77
+ !scrollView.isHidden, scrollView.window != nil, self.scale == scale,
78
+ nodes.allSatisfy({ node in
79
+ guard let next = node.scroll else { return false }
80
+ return next.id == placement.id && next.viewport == placement.viewport && next.clip == placement.clip &&
81
+ next.contentWidth == placement.contentWidth && next.contentHeight == placement.contentHeight
82
+ }) else { return false }
83
+ setFrames(nodes)
84
+ return true
85
+ }
86
+
87
+ private func setFrames(_ nodes: [NativeUIScrollNode]) {
88
+ frames = Dictionary(uniqueKeysWithValues: nodes.map {
89
+ ($0.id, $0.scroll!.frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale))
90
+ .offsetBy(dx: contentOrigin.x, dy: contentOrigin.y))
91
+ })
92
+ }
93
+
94
+ public func update() {
95
+ guard let webView, let scrollView, scrollView.isDescendant(of: webView) else {
96
+ frames.forEach { render($0.key, $0.value, .null) }
97
+ return
98
+ }
99
+ let origin = scrollView.convert(scrollView.bounds, to: webView).origin
100
+ let viewport = CGRect(x: origin.x + viewportCorrection.x, y: origin.y + viewportCorrection.y,
101
+ width: placement.viewport.width * scale, height: placement.viewport.height * scale)
102
+ let visible = placement.clip.rect.applying(CGAffineTransform(scaleX: scale, y: scale))
103
+ .intersection(viewport).intersection(webView.bounds)
104
+ UIView.performWithoutAnimation {
105
+ for (id, frame) in frames {
106
+ render(id, frame.offsetBy(dx: viewport.minX - scrollView.contentOffset.x,
107
+ dy: viewport.minY - scrollView.contentOffset.y), visible)
108
+ }
109
+ }
110
+ }
111
+
112
+ private static func scrollViews(in view: UIView) -> [UIScrollView] {
113
+ view.subviews.flatMap { child in
114
+ ((child as? UIScrollView).map { [$0] } ?? []) + scrollViews(in: child)
115
+ }
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@webview-native-ui/engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Native UI placement, scroll tracking, and Web projection for Capacitor and Cordova plugin maintainers.",
5
+ "type": "module",
6
+ "license": "Zlib",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/webview-native-ui/engine.git"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./ionic": {
17
+ "types": "./dist/ionic.d.ts",
18
+ "import": "./dist/ionic.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "ios/Sources",
24
+ "WebViewNativeUIEngine.podspec",
25
+ "README.md",
26
+ "LICENSE",
27
+ "NOTICE",
28
+ "docs"
29
+ ],
30
+ "scripts": {
31
+ "build": "rm -rf dist && tsc -p tsconfig.json && node scripts/build-runtime.mjs",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "playwright test",
34
+ "lint": "npm run typecheck && eslint . --max-warnings 0 && npm run format:check",
35
+ "format": "prettier --write \"**/*.{ts,js,mjs,cjs,json,md,yml,yaml,html}\"",
36
+ "prepublishOnly": "npm run build",
37
+ "test:release": "node --test scripts/native-ui-engine-release/publish.test.mjs",
38
+ "test:package": "node scripts/verify-package.mjs",
39
+ "release": "np --no-publish --no-tests --branch main",
40
+ "test:cordova": "node scripts/verify-cordova.mjs",
41
+ "format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,json,md,yml,yaml,html}\""
42
+ },
43
+ "devDependencies": {
44
+ "@eslint/js": "^10.0.1",
45
+ "@playwright/test": "^1.58.0",
46
+ "esbuild": "^0.27.0",
47
+ "eslint": "^10.13.0",
48
+ "globals": "^17.13.0",
49
+ "np": "^10.2.0",
50
+ "prettier": "^3.6.2",
51
+ "typescript": "^5.9.3",
52
+ "typescript-eslint": "^8.72.1"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "peerDependencies": {
58
+ "@capacitor/core": ">=8.0.0 <9"
59
+ },
60
+ "types": "./dist/index.d.ts",
61
+ "peerDependenciesMeta": {
62
+ "@capacitor/core": {
63
+ "optional": true
64
+ }
65
+ }
66
+ }