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