import UIKit import WebKit public protocol RDEPUBWebViewDelegate: AnyObject { func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) } public extension RDEPUBWebViewDelegate { func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {} func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {} func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {} } final class RDEPUBAnnotationWebView: WKWebView { var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? // M-15: Backing store for UIEditMenuInteraction (iOS 16+). // Stored as Any? to avoid @available on stored property restriction. var _editMenuInteraction: Any? override var canBecomeFirstResponder: Bool { true } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { switch action { case #selector(rd_copy(_:)), #selector(rd_highlight(_:)), #selector(rd_annotate(_:)): return true default: return false } } @objc func rd_copy(_ sender: Any?) { onSelectionAction?(.copy) } @objc func rd_highlight(_ sender: Any?) { onSelectionAction?(.highlight) } @objc func rd_annotate(_ sender: Any?) { onSelectionAction?(.annotate) } } // MARK: - UIEditMenuInteractionDelegate (iOS 16+) @available(iOS 16.0, *) extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate { func editMenuInteraction( _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration ) -> UIMenu { UIMenu(children: [ UICommand(title: "复制", action: #selector(rd_copy(_:))), UICommand(title: "划线", action: #selector(rd_highlight(_:))), UICommand(title: "注释", action: #selector(rd_annotate(_:))) ]) } func editMenuInteraction( _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration ) -> CGRect { bounds } } public final class RDEPUBWebView: UIView { public weak var delegate: RDEPUBWebViewDelegate? public var onRendered: (() -> Void)? var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)? var publication: RDEPUBPublication? var currentRenderRequest: RDEPUBRenderRequest? var webView: WKWebView? var schemeHandler: RDEPUBResourceURLSchemeHandler? var configuredPublicationKey: String? var currentLoadSignature: String? var currentSpineIndex = 0 var currentHref = "" var currentPageIndex = 0 var currentTotalPagesInChapter = 1 var viewportSize: CGSize = .zero var currentPadding: UIEdgeInsets = .zero var currentFontSize: CGFloat = 16 var currentLineHeightMultiple: CGFloat = 1.5 var currentThemeBackgroundColor: String? var currentThemeTextColor: String? var targetLocation: RDEPUBLocation? var pendingHighlights: [RDEPUBHighlight] = [] var fixedSpread: EPUBFixedSpread? var isFixedLayout = false var isReaderPageVisible = true var hasReaderPageBeenVisible = false var needsReaderPagePlaybackRestart = false var appliedNativeMediaPlaybackSuspended = false var isNativeMediaPlaybackOperationInFlight = false var nativeMediaPlaybackOperationGeneration: UInt = 0 var nativeMediaPlaybackRequests: [(suspended: Bool, completion: () -> Void)] = [] var isWaitingForReaderPageMediaNavigation = false var pendingReaderPageMediaNavigation: WKNavigation? var isReaderPageMediaActivationBlocked = false var pendingProgressionRequest = false var fixedLayoutReadyWorkItem: DispatchWorkItem? var didRenderCurrentRequest = false let debugScope = "ReaderWebView" public override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // M-08: script message handler 经 RDEPUBWeakScriptMessageHandler 弱代理注册 // (见 configureWebViewIfNeeded),webView → userContentController → self // 的保留环不再成立,deinit 会随最后一个持有者释放而正常触发。 // reset() 仍可用于在丢弃路径上提前拆除 WKWebView(不必等 dealloc)。 deinit { teardownWebView() } public override func layoutSubviews() { super.layoutSubviews() webView?.frame = bounds } public func reset() { delegate = nil onRendered = nil onDecorationsResolved = nil publication = nil currentRenderRequest = nil currentSpineIndex = 0 currentHref = "" currentPageIndex = 0 currentTotalPagesInChapter = 1 viewportSize = .zero currentPadding = .zero currentFontSize = 16 currentLineHeightMultiple = 1.5 currentThemeBackgroundColor = nil currentThemeTextColor = nil targetLocation = nil pendingHighlights = [] fixedSpread = nil isFixedLayout = false isReaderPageVisible = true hasReaderPageBeenVisible = false needsReaderPagePlaybackRestart = false isWaitingForReaderPageMediaNavigation = false pendingReaderPageMediaNavigation = nil isReaderPageMediaActivationBlocked = false pendingProgressionRequest = false didRenderCurrentRequest = false teardownWebView() } public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { configureWebViewIfNeeded(publication: publication) guard let webView else { return } self.publication = publication self.currentRenderRequest = request self.currentSpineIndex = request.primarySpineIndex self.currentHref = request.primaryHref self.didRenderCurrentRequest = false cancelFixedLayoutReadyFallback() let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? "" let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request) if request.isFixedLayout { // A fixed-layout load replaces the whole Hype document. Keep WebKit // suspended until navigation finishes so the outgoing page cannot // overlap the new page's zero-second narration. hasReaderPageBeenVisible = false needsReaderPagePlaybackRestart = false isWaitingForReaderPageMediaNavigation = true setNativeReaderPageMediaPlaybackSuspended(true) } RDEPUBWebViewDebug.log( debugScope, message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))" ) switch request { case .reflowable(let reflowableRequest): handleReflowableLoad(publication: publication, request: reflowableRequest, loadSignature: loadSignature, in: webView) case .fixed(let fixedRequest): handleFixedLayoutLoad(publication: publication, request: fixedRequest, loadSignature: loadSignature, in: webView) } } func pageLoadSignature( publicationKey: String, request: RDEPUBRenderRequest ) -> String { switch request { case .reflowable(let request): return reflowableLoadSignature(publicationKey: publicationKey, request: request) case .fixed(let request): return fixedLayoutLoadSignature(publicationKey: publicationKey, request: request) } } func rendered() { guard !didRenderCurrentRequest else { return } didRenderCurrentRequest = true cancelFixedLayoutReadyFallback() RDEPUBWebViewDebug.log(debugScope, message: "rendered spine=\(currentSpineIndex) href=\(currentHref) fixed=\(isFixedLayout)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self else { return } self.delegate?.epubWebViewDidFinishRendering(self) self.onRendered?() } } }