ReadViewSDK/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift

252 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}