252 lines
10 KiB
Swift
252 lines
10 KiB
Swift
|
||
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)
|
||
}
|
||
}
|