ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

671 lines
26 KiB
Swift

import UIKit
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
return
}
let currentPage = activePages[readerView.currentPage]
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
return
}
persist(location: location)
readingSession?.updateReadingContext(
pageNumber: readerView.currentPage + 1,
location: location,
spineIndex: spineIndex,
chapterIndex: currentPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
} else {
updateCurrentSelection(nil)
}
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
return
}
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
relativeToSpineIndex: fromSpineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
openExternalURLIfAllowed(url)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
#endif
}
}
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
return
}
updateCurrentSelection(selection)
}
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
readerView.handleContentTap(at: point, in: contentView)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection?
) {
handleSelectionMenuAction(action, selection: selection ?? currentSelection)
contentView.clearSelection()
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect,
sourcePoint: CGPoint
) {
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
) {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex: Int
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
localPageIndex = summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
} else {
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
}
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook, let publication {
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: resolvedPage.page.spineIndex,
chapterIndex: resolvedPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
return
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
return offset
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
if let fragment = location.fragment,
let offset = fallbackEntry.fragmentOffsets[fragment] {
return offset
}
return 0
}
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
guard pageCount > 1 else { return 0 }
return min(
pageCount - 1,
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
)
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
bestOffset = fragmentOffset
bestID = fragmentID
}
return bestID
}
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
return false
}
return configuration.allowedExternalURLSchemes.contains(scheme)
}
private func openExternalURLIfAllowed(_ url: URL) {
guard shouldAllowExternalURL(url) else { return }
if configuration.requiresExternalLinkConfirmation {
presentExternalLinkConfirmation(for: url)
} else {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func presentExternalLinkConfirmation(for url: URL) {
let alert = UIAlertController(
title: "打开外部链接",
message: url.absoluteString,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
})
present(alert, animated: true)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
guard let publication else { return false }
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
let sourceLocation = currentVisibleLocation()
let sourceCFI = sourceLocation?.cfi
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
guard let note = resolver.resolveInternalLink(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetLocation: location,
sourceLocation: sourceLocation,
relativeToSpineIndex: fromSpineIndex
) else {
return false
}
RDEPUBNotePopupCoordinator.present(
note,
from: self,
onReturnToSource: { [weak self] in
guard let self, let sourceLocation = note.sourceLocation else { return }
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
},
onOpenNoteLocation: { [weak self] in
guard let self else { return }
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
}
)
return true
}
private func navigateToLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int?,
animated: Bool
) {
guard let pageNumber = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: spineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.onBackgroundTap = { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
let tooltip = RDEPUBAttachmentTooltipView()
tooltip.alpha = 0
let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
let anchorRect = sourceView.convert(sourceRect, to: view)
let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
let anchorPoint = CGPoint(
x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
)
let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size
let idealX = anchorPoint.x - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX)
let topSafeY = view.safeAreaInsets.top + 12
let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
let availableSpaceAbove = anchorRect.minY - topSafeY
let availableSpaceBelow = bottomSafeY - anchorRect.maxY
let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
let originY: CGFloat
switch tooltipPlacement {
case .bottom:
originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
case .top:
originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
}
let arrowTipX = min(
max(anchorPoint.x - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip)
view.addSubview(overlay)
UIView.animate(withDuration: 0.2) {
tooltip.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
}
private func hideAttachmentTooltipIfNeeded() {
view.subviews
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
.forEach { $0.removeFromSuperview() }
}
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
UIView.animate(withDuration: 0.18, animations: {
overlay.tooltipView?.alpha = 0
}, completion: { _ in
overlay.removeFromSuperview()
})
}
}
private extension RDEPUBReaderController {
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tapGesture.cancelsTouchesInView = false
addGestureRecognizer(tapGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
guard let tooltipView else {
onBackgroundTap?()
return
}
if !tooltipView.frame.contains(point) {
onBackgroundTap?()
}
}
}
private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textColor = .white
label.font = .systemFont(ofSize: 16, weight: .regular)
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
layer.addSublayer(shapeLayer)
addSubview(textLabel)
accessibilityIdentifier = "epub.reader.attachment.tooltip"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.frame = bounds
shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
let labelFrame = bounds.inset(by: UIEdgeInsets(
top: topInset,
left: contentInsets.left,
bottom: bottomInset,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value
arrowPlacement = placement
setNeedsLayout()
}
func configure(text: String, maxWidth: CGFloat) {
textLabel.text = text
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
frame.size = CGSize(
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
)
setNeedsLayout()
layoutIfNeeded()
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect: CGRect
switch arrowPlacement {
case .bottom:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
case .top:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY + arrowSize.height,
width: rect.width,
height: rect.height - arrowSize.height
)
}
let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
)
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
switch arrowPlacement {
case .bottom:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
case .top:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
}
path.close()
return path
}
}