feat: improve epub reader controls and annotations

This commit is contained in:
shen
2026-07-12 12:23:10 +08:00
parent 063a493b18
commit 8ccb7157b2
36 changed files with 2959 additions and 2160 deletions
@@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
public var note: String?
/// True when this record was created directly as an annotation instead of
/// adding a note to an existing underline.
public var isAnnotationOnly: Bool
public var createdAt: Date
public init(
@@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
style: RDEPUBHighlightStyle = .highlight,
style: RDEPUBHighlightStyle = .underline,
color: String = "#F8E16C",
note: String? = nil,
isAnnotationOnly: Bool = false,
createdAt: Date = Date()
) {
self.id = id
@@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
self.style = style
self.color = color
self.note = note?.nilIfEmpty
self.isAnnotationOnly = isAnnotationOnly
self.createdAt = createdAt
}
@@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
case style
case color
case note
case isAnnotationOnly
case createdAt
}
@@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
style = decodedStyle
style = decodedStyle == .highlight ? .underline : decodedStyle
} else {
style = .highlight
style = .underline
}
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
isAnnotationOnly = try container.decodeIfPresent(Bool.self, forKey: .isAnnotationOnly) ?? false
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
@@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
try container.encode(style, forKey: .style)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
try container.encode(createdAt, forKey: .createdAt)
}
@@ -36,6 +36,13 @@ public final class RDEPUBPublication {
parser.readingProfile()
}
/// Scripted fixed-layout publications own a complete interactive scene per
/// spine item. Combining scenes in landscape can start multiple timelines
/// and media tracks, so these publications remain single-page.
public lazy var requiresSinglePagePresentation: Bool = {
layout == .fixed && parser.hasInteractiveContent()
}()
public var readingProgression: RDEPUBReadingProgression {
metadata.readingProgression
}
@@ -68,4 +75,4 @@ public final class RDEPUBPublication {
spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize)
)
}
}
}
@@ -56,9 +56,9 @@ extension RDEPUBWebView {
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(_:)))
UIMenuItem(title: "复制", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
UIMenuItem(title: "划线", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
UIMenuItem(title: "", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
]
}
webView.navigationDelegate = self
@@ -89,6 +89,11 @@ extension RDEPUBWebView {
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)")
}
@@ -103,6 +108,7 @@ extension RDEPUBWebView {
func teardownWebView() {
cancelFixedLayoutReadyFallback()
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
if let webView {
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
}
@@ -119,6 +125,10 @@ extension RDEPUBWebView {
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
webView?.removeFromSuperview()
webView = nil
finishAllNativeMediaPlaybackRequests()
isWaitingForReaderPageMediaNavigation = false
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
schemeHandler = nil
configuredPublicationKey = nil
currentLoadSignature = nil
@@ -143,11 +153,101 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan
}
}
/// 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, including audio created with `new Audio()` that is
/// not attached to the DOM (as used by Tumult Hype publications).
/// a suspended state for both HTML media and Web Audio. Tumult Hype uses
/// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling
/// only `<audio>` and `new Audio()` leaves narration running on cached pages.
static let readerPageMediaLifecycleUserScript = #"""
(() => {
if (window.__rdReaderMediaLifecycleInstalled) return;
@@ -155,10 +255,17 @@ extension RDEPUBWebView {
let isActive = false;
const trackedMedia = new Set();
const trackedAudioContexts = new Set();
const lifecycleSuspendedAudioContexts = new Set();
const command = "rd-reader-page-visibility";
const frameReadyCommand = "rd-reader-frame-media-ready";
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
const nativePlay = mediaPrototype && mediaPrototype.play;
const ignoreRejection = result => {
if (result && result.catch) result.catch(() => {});
};
const register = media => {
if (media) trackedMedia.add(media);
return media;
@@ -169,17 +276,47 @@ extension RDEPUBWebView {
document.querySelectorAll("audio,video").forEach(register);
};
const updateAudioContext = context => {
if (!context || context.state === "closed") return;
if (isActive) {
if (!lifecycleSuspendedAudioContexts.has(context)) return;
try {
const result = context.resume();
if (result && result.then) {
result.then(() => lifecycleSuspendedAudioContexts.delete(context)).catch(() => {});
} else {
lifecycleSuspendedAudioContexts.delete(context);
}
} catch (_) {}
return;
}
if (context.state !== "running" || lifecycleSuspendedAudioContexts.has(context)) return;
lifecycleSuspendedAudioContexts.add(context);
try { ignoreRejection(context.suspend()); } catch (_) {}
};
const registerAudioContext = context => {
if (context) {
trackedAudioContexts.add(context);
updateAudioContext(context);
}
return context;
};
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(() => {});
if (result && result.then) {
result.then(() => { media.__rdReaderReplayWhenVisible = false; }).catch(() => {});
} else {
media.__rdReaderReplayWhenVisible = false;
}
} catch (_) {}
}
} else {
@@ -191,6 +328,7 @@ extension RDEPUBWebView {
}
}
});
trackedAudioContexts.forEach(updateAudioContext);
};
if (mediaPrototype && nativePlay) {
@@ -218,34 +356,212 @@ extension RDEPUBWebView {
window.Audio = ReaderAudio;
}
const installAudioContext = name => {
const NativeAudioContext = window[name];
if (typeof NativeAudioContext !== "function") return;
const ReaderAudioContext = function() {
const context = Reflect.construct(
NativeAudioContext,
Array.prototype.slice.call(arguments),
NativeAudioContext
);
return registerAudioContext(context);
};
ReaderAudioContext.prototype = NativeAudioContext.prototype;
try { Object.setPrototypeOf(ReaderAudioContext, NativeAudioContext); } catch (_) {}
try { window[name] = ReaderAudioContext; } catch (_) {}
};
installAudioContext("AudioContext");
installAudioContext("webkitAudioContext");
const sendVisibility = target => {
try {
target.postMessage({ type: command, active: isActive }, "*");
} catch (_) {}
};
const broadcastVisibility = () => {
for (let index = 0; index < window.frames.length; index += 1) {
sendVisibility(window.frames[index]);
}
};
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 (_) {}
if (!message) return;
if (message.type === frameReadyCommand) {
if (event.source) sendVisibility(event.source);
return;
}
if (message.type !== command) return;
setActive(message.active);
broadcastVisibility();
});
document.addEventListener("DOMContentLoaded", () => setActive(isActive));
document.addEventListener("DOMContentLoaded", () => {
setActive(isActive);
broadcastVisibility();
});
if (window.MutationObserver) {
new MutationObserver(() => setActive(isActive)).observe(document, {
childList: true,
subtree: true
});
}
if (window.parent !== window) {
try { window.parent.postMessage({ type: frameReadyCommand }, "*"); } catch (_) {}
}
})();
"""#
func setReaderPageVisible(_ isVisible: Bool) {
let wasVisible = isReaderPageVisible
isReaderPageVisible = isVisible
if !isVisible {
if wasVisible,
hasReaderPageBeenVisible,
currentRenderRequest?.isFixedLayout == true {
needsReaderPagePlaybackRestart = true
}
applyReaderPageMediaVisibility()
return
}
if !wasVisible,
needsReaderPagePlaybackRestart,
restartReaderPagePlayback() {
return
}
guard !isWaitingForReaderPageMediaNavigation else { return }
applyReaderPageMediaVisibility()
}
func readerPageMediaNavigationDidFinish(_ navigation: WKNavigation?) -> Bool {
if isWaitingForReaderPageMediaNavigation,
let pendingReaderPageMediaNavigation,
let navigation,
pendingReaderPageMediaNavigation !== navigation {
return false
}
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
isWaitingForReaderPageMediaNavigation = false
applyReaderPageMediaVisibility()
return true
}
func readerPageMediaNavigationDidFail(_ navigation: WKNavigation?) {
if let pendingReaderPageMediaNavigation,
let navigation,
pendingReaderPageMediaNavigation !== navigation {
return
}
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = true
isWaitingForReaderPageMediaNavigation = false
// A failed fixed-layout reload may leave the outgoing Hype document
// alive. Keep it suspended instead of reviving narration from the page
// the user already left; the next successful load will activate again.
setNativeReaderPageMediaPlaybackSuspended(true)
webView?.evaluateJavaScript(
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
completionHandler: nil
)
}
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)
let isVisible = isReaderPageVisible && !isReaderPageMediaActivationBlocked
if !isVisible {
setNativeReaderPageMediaPlaybackSuspended(true)
webView.evaluateJavaScript(
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
completionHandler: nil
)
return
}
hasReaderPageBeenVisible = true
setNativeReaderPageMediaPlaybackSuspended(false) { [weak self, weak webView] in
guard let self,
let webView,
self.webView === webView,
self.isReaderPageVisible,
!self.isReaderPageMediaActivationBlocked,
!self.isWaitingForReaderPageMediaNavigation else { return }
webView.evaluateJavaScript(
"window.postMessage({type: 'rd-reader-page-visibility', active: true}, '*');",
completionHandler: nil
)
}
}
private func restartReaderPagePlayback() -> Bool {
guard let publication,
let currentRenderRequest,
currentRenderRequest.isFixedLayout else { return false }
load(publication: publication, request: currentRenderRequest)
return true
}
func setNativeReaderPageMediaPlaybackSuspended(
_ suspended: Bool,
completion: @escaping () -> Void = {}
) {
RDEPUBReaderMediaPlaybackCoordinator.shared.setPlaybackSuspended(
suspended,
for: self,
completion: completion
)
}
func applyNativeMediaPlaybackSuspended(_ suspended: Bool, completion: @escaping () -> Void) {
nativeMediaPlaybackRequests.append((suspended, completion))
processNextNativeMediaPlaybackRequest()
}
private func processNextNativeMediaPlaybackRequest() {
guard !isNativeMediaPlaybackOperationInFlight else { return }
guard let webView else {
finishAllNativeMediaPlaybackRequests()
return
}
guard let request = nativeMediaPlaybackRequests.first else { return }
guard appliedNativeMediaPlaybackSuspended != request.suspended else {
nativeMediaPlaybackRequests.removeFirst()
request.completion()
processNextNativeMediaPlaybackRequest()
return
}
let targetSuspended = request.suspended
isNativeMediaPlaybackOperationInFlight = true
nativeMediaPlaybackOperationGeneration &+= 1
let operationGeneration = nativeMediaPlaybackOperationGeneration
webView.setAllMediaPlaybackSuspended(targetSuspended) { [weak self, weak webView] in
guard let self else { return }
guard operationGeneration == self.nativeMediaPlaybackOperationGeneration,
let webView,
self.webView === webView else { return }
self.appliedNativeMediaPlaybackSuspended = targetSuspended
self.isNativeMediaPlaybackOperationInFlight = false
guard let completedRequest = self.nativeMediaPlaybackRequests.first else { return }
self.nativeMediaPlaybackRequests.removeFirst()
completedRequest.completion()
self.processNextNativeMediaPlaybackRequest()
}
}
private func finishAllNativeMediaPlaybackRequests() {
let completions = nativeMediaPlaybackRequests.map(\.completion)
nativeMediaPlaybackRequests.removeAll()
nativeMediaPlaybackOperationGeneration &+= 1
appliedNativeMediaPlaybackSuspended = false
isNativeMediaPlaybackOperationInFlight = false
completions.forEach { $0() }
}
}
@@ -48,7 +48,7 @@ extension RDEPUBWebView {
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
webView.loadHTMLString(
pendingReaderPageMediaNavigation = webView.loadHTMLString(
html,
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
)
@@ -31,20 +31,33 @@ extension RDEPUBWebView: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
applyReaderPageMediaVisibility()
guard readerPageMediaNavigationDidFinish(navigation) else { return }
applyPresentation()
}
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
// WebKit may not complete an in-flight media suspension after its
// content process exits. Tearing down flushes the FIFO callbacks and
// lets the coordinator activate another page without deadlocking.
// Rebuild the page afterward so a cached/current page does not remain
// permanently blank after WebKit recovers its content process.
let publication = publication
let renderRequest = currentRenderRequest
teardownWebView()
if let publication, let renderRequest {
load(publication: publication, request: renderRequest)
}
}
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
readerPageMediaNavigationDidFail(navigation)
}
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
readerPageMediaNavigationDidFail(navigation)
}
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -74,9 +74,9 @@ extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
menuFor configuration: UIEditMenuConfiguration
) -> UIMenu {
UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
UICommand(title: "", action: #selector(rd_annotate(_:)))
UICommand(title: "复制", action: #selector(rd_copy(_:))),
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
UICommand(title: "", action: #selector(rd_annotate(_:)))
])
}
@@ -138,6 +138,24 @@ public final class RDEPUBWebView: UIView {
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?
@@ -189,6 +207,11 @@ public final class RDEPUBWebView: UIView {
fixedSpread = nil
isFixedLayout = false
isReaderPageVisible = true
hasReaderPageBeenVisible = false
needsReaderPagePlaybackRestart = false
isWaitingForReaderPageMediaNavigation = false
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
pendingProgressionRequest = false
didRenderCurrentRequest = false
teardownWebView()
@@ -207,6 +230,15 @@ public final class RDEPUBWebView: UIView {
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))"