import UIKit import WebKit extension RDEPUBWebView { func configureWebViewIfNeeded(publication: RDEPUBPublication) { let parser = publication.parser let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString if publicationKey == configuredPublicationKey, webView != nil { return } teardownWebView() let userContentController = WKUserContentController() let messageHandlerProxy = RDEPUBWeakScriptMessageHandler(target: self) RDEPUBJavaScriptBridge.messageNames.forEach { userContentController.add(messageHandlerProxy, name: $0) } userContentController.addUserScript( WKUserScript( source: RDEPUBJavaScriptBridge.userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true ) ) userContentController.addUserScript( WKUserScript( source: Self.readerPageMediaLifecycleUserScript, injectionTime: .atDocumentStart, forMainFrameOnly: false ) ) let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() configuration.userContentController = userContentController // Interactive fixed-layout EPUBs commonly start narration from their // document-load timeline. Allow those page-owned audio elements to play // without requiring an extra tap, and keep playback inside the reader. configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser) configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme) let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration) webView.onSelectionAction = { [weak self] action in guard let self else { return } self.handleSelectionMenuAction(action) } // M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController if #available(iOS 16.0, *) { let interaction = UIEditMenuInteraction(delegate: webView) webView.addInteraction(interaction) webView._editMenuInteraction = interaction } else { UIMenuController.shared.menuItems = [ UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))), UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))), UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:))) ] } webView.navigationDelegate = self // 安全区自适应会把 Web 布局视口缩小(横屏左右各 59pt、底部 21pt), // 固定布局页面随之按缩小后的视口适配,产生大边距/中缝。 // 阅读区域的安全区避让统一由 fixedContentInset / 排版 CSS 负责。 webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.isScrollEnabled = false webView.scrollView.showsHorizontalScrollIndicator = false webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.bounces = false webView.scrollView.alwaysBounceHorizontal = false webView.scrollView.alwaysBounceVertical = false webView.scrollView.clipsToBounds = true webView.scrollView.panGestureRecognizer.isEnabled = false webView.scrollView.pinchGestureRecognizer?.isEnabled = false webView.isOpaque = false webView.backgroundColor = .clear webView.clipsToBounds = true if #available(iOS 16.4, *) { webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled } addSubview(webView) webView.frame = bounds webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] self.schemeHandler = schemeHandler self.webView = webView self.configuredPublicationKey = publicationKey RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") } func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) { delegate?.epubWebView(self, didRequestSelectionAction: action) clearWebSelection() } func clearWebSelection() { webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();") } func teardownWebView() { cancelFixedLayoutReadyFallback() if let webView { RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))") } // M-15: Clean up UIEditMenuInteraction on iOS 16+ if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction { webView?.removeInteraction(interaction) } webView?.navigationDelegate = nil if let userContentController = webView?.configuration.userContentController { RDEPUBJavaScriptBridge.messageNames.forEach { userContentController.removeScriptMessageHandler(forName: $0) } } (webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil webView?.removeFromSuperview() webView = nil schemeHandler = nil configuredPublicationKey = nil currentLoadSignature = nil } } /// M-08: WKUserContentController 强持有注册的 script message handler。 /// 直接注册 RDEPUBWebView 自身会形成保留环 /// (webView → userContentController → RDEPUBWebView → webView), /// 被丢弃的页面 WebView 将永远无法释放。本代理弱持有真实 handler, /// 保留环从一开始就不成立,RDEPUBWebView 可随最后一个持有者正常 deinit。 private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHandler { private weak var target: WKScriptMessageHandler? init(target: WKScriptMessageHandler) { self.target = target } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { target?.userContentController(userContentController, didReceive: message) } } extension RDEPUBWebView { /// Installed in every frame before EPUB scripts run. Hidden pages start in /// a suspended state, including audio created with `new Audio()` that is /// not attached to the DOM (as used by Tumult Hype publications). static let readerPageMediaLifecycleUserScript = #""" (() => { if (window.__rdReaderMediaLifecycleInstalled) return; window.__rdReaderMediaLifecycleInstalled = true; let isActive = false; const trackedMedia = new Set(); const command = "rd-reader-page-visibility"; const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype; const nativePlay = mediaPrototype && mediaPrototype.play; const register = media => { if (media) trackedMedia.add(media); return media; }; const collectDocumentMedia = () => { if (!document.querySelectorAll) return; document.querySelectorAll("audio,video").forEach(register); }; const setActive = active => { isActive = !!active; collectDocumentMedia(); trackedMedia.forEach(media => { if (isActive) { if (media.__rdReaderReplayWhenVisible) { media.__rdReaderReplayWhenVisible = false; try { media.currentTime = 0; const result = nativePlay && nativePlay.call(media); if (result && result.catch) result.catch(() => {}); } catch (_) {} } } else { if (media.__rdReaderWasPlayed || media.autoplay || (!media.paused && !media.ended)) { media.__rdReaderReplayWhenVisible = true; } if (!media.paused) { try { media.pause(); } catch (_) {} } } }); }; if (mediaPrototype && nativePlay) { mediaPrototype.play = function() { register(this); this.__rdReaderWasPlayed = true; if (!isActive) { this.__rdReaderReplayWhenVisible = true; try { this.pause(); } catch (_) {} return Promise.resolve(); } this.__rdReaderReplayWhenVisible = false; return nativePlay.apply(this, arguments); }; } const NativeAudio = window.Audio; if (typeof NativeAudio === "function") { const ReaderAudio = function(source) { const media = arguments.length > 0 ? new NativeAudio(source) : new NativeAudio(); return register(media); }; ReaderAudio.prototype = NativeAudio.prototype; try { Object.setPrototypeOf(ReaderAudio, NativeAudio); } catch (_) {} window.Audio = ReaderAudio; } window.addEventListener("message", event => { const message = event.data; if (!message || message.type !== command) return; setActive(message.active); for (let index = 0; index < window.frames.length; index += 1) { try { window.frames[index].postMessage(message, "*"); } catch (_) {} } }); document.addEventListener("DOMContentLoaded", () => setActive(isActive)); if (window.MutationObserver) { new MutationObserver(() => setActive(isActive)).observe(document, { childList: true, subtree: true }); } })(); """# func setReaderPageVisible(_ isVisible: Bool) { isReaderPageVisible = isVisible applyReaderPageMediaVisibility() } func applyReaderPageMediaVisibility() { guard let webView else { return } let active = isReaderPageVisible ? "true" : "false" let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');" webView.evaluateJavaScript(script, completionHandler: nil) } }