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 appliedNativeMediaPlaybackSuspended = false isNativeMediaPlaybackOperationInFlight = false nativeMediaPlaybackOperationGeneration &+= 1 nativeMediaPlaybackRequests.removeAll() setNativeReaderPageMediaPlaybackSuspended(true) 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() RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self) 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 finishAllNativeMediaPlaybackRequests() isWaitingForReaderPageMediaNavigation = false pendingReaderPageMediaNavigation = nil isReaderPageMediaActivationBlocked = false 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) } } /// Serializes native media activation across page WebViews. WebKit suspension /// is asynchronous and separate WKWebView instances have no ordering contract; /// the incoming page therefore waits for the previous active page's suspension /// completion before it can be resumed. private final class RDEPUBReaderMediaPlaybackCoordinator { static let shared = RDEPUBReaderMediaPlaybackCoordinator() private weak var activePage: RDEPUBWebView? private weak var pendingPage: RDEPUBWebView? private var activationGeneration: UInt = 0 func setPlaybackSuspended( _ suspended: Bool, for page: RDEPUBWebView, completion: @escaping () -> Void = {} ) { if suspended { suspend(page, completion: completion) } else { activate(page, completion: completion) } } func remove(_ page: RDEPUBWebView) { if pendingPage === page { activationGeneration &+= 1 } if activePage === page { activePage = nil } if pendingPage === page { pendingPage = nil } } private func suspend(_ page: RDEPUBWebView, completion: @escaping () -> Void) { if pendingPage === page || (activePage === page && pendingPage == nil) { activationGeneration &+= 1 } if pendingPage === page { pendingPage = nil } let suspensionGeneration = activationGeneration page.applyNativeMediaPlaybackSuspended(true) { [weak self, weak page] in guard let self, let page else { return } if self.activationGeneration == suspensionGeneration, self.activePage === page { self.activePage = nil } completion() } } private func activate(_ page: RDEPUBWebView, completion: @escaping () -> Void) { activationGeneration &+= 1 let generation = activationGeneration pendingPage = page let outgoingPage = activePage let resumeIncomingPage = { [weak self, weak page] in guard let self, let page, self.activationGeneration == generation, self.pendingPage === page else { return } self.pendingPage = nil self.activePage = page page.applyNativeMediaPlaybackSuspended(false) { [weak self, weak page] in guard let self, let page else { return } if self.activePage !== page { page.applyNativeMediaPlaybackSuspended(true, completion: {}) } else { completion() } } } guard let outgoingPage, outgoingPage !== page else { resumeIncomingPage() return } outgoingPage.applyNativeMediaPlaybackSuspended(true) { resumeIncomingPage() } } } extension RDEPUBWebView { /// Installed in every frame before EPUB scripts run. Hidden pages start in /// a suspended state for both HTML media and Web Audio. Tumult Hype uses /// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling /// only `