feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化

- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
This commit is contained in:
shen
2026-06-13 22:48:56 +08:00
parent 27e9b85ddb
commit 6f75b083f7
83 changed files with 4824 additions and 1879 deletions
@@ -62,15 +62,17 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
/// Web 使
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
UIApplication.shared.open(url, options: [:], completionHandler: nil)
openExternalURLIfAllowed(url)
}
/// Web JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
#endif
}
}
@@ -96,6 +98,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
contentView.clearSelection()
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect
) {
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
@@ -296,4 +306,242 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
return bestID
}
// MARK: -
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 presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) {
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
tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320))
let anchorRect = sourceView.convert(sourceRect, to: view)
let horizontalPadding: CGFloat = 24
let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size
let idealX = anchorRect.midX - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX)
let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing)
let arrowTipX = min(
max(anchorRect.midX - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX)
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 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 {
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 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 labelFrame = bounds.inset(by: UIEdgeInsets(
top: contentInsets.top,
left: contentInsets.left,
bottom: contentInsets.bottom + arrowSize.height,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat) {
arrowTipX = value
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(
x: rect.minX,
y: rect.minY,
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()
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
)
path.close()
return path
}
}
@@ -97,16 +97,28 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil }
let matches = globalSearchState.matches.filter { searchMatch in
searchMatchBelongsToPage(searchMatch, page: page)
let matches: [RDEPUBSearchMatch]
let currentMatchIndex: Int?
if let chapterData = chapterData(for: page),
let resolvedState = resolvedSearchState(
for: page,
chapterData: chapterData,
globalSearchState: globalSearchState
) {
matches = resolvedState.matches
currentMatchIndex = resolvedState.currentMatchIndex
} else {
matches = globalSearchState.matches.filter { searchMatch in
searchMatchBelongsToPage(searchMatch, page: page)
}
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
}
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: matches,
@@ -114,6 +126,126 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
)
}
private func resolvedSearchState(
for page: RDEPUBTextPage,
chapterData: RDEPUBChapterData,
globalSearchState: RDEPUBSearchState
) -> RDEPUBSearchState? {
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
let normalizedHref = normalizedPageHref(for: page)
let exactMatches = exactChapterSearchMatches(
in: chapterData,
keyword: normalizedKeyword,
normalizedHref: normalizedHref
)
let pageMatches = exactMatches.filter { match in
guard let rangeLocation = match.rangeLocation else { return false }
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
return NSIntersectionRange(range, page.contentRange).length > 0
}
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
}
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: pageMatches,
currentMatchIndex: currentMatchIndex
)
}
private func exactChapterSearchMatches(
in chapterData: RDEPUBChapterData,
keyword: String,
normalizedHref: String
) -> [RDEPUBSearchMatch] {
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return [] }
var matches: [RDEPUBSearchMatch] = []
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: max(foundRange.length, 1),
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return matches
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData
}
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
return nil
}
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
if let textBook,
let chapterData = textBook.chapterData(for: page.href),
@@ -60,11 +60,16 @@ extension RDEPUBReaderController {
}
let page = activePages[pageIndex]
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
forPageNumber: pageIndex + 1,
spineIndex: page.spineIndex
)
return currentPreferences().renderRequest(
for: page,
publication: publication,
viewportSize: currentLayoutContext().viewportSize,
targetLocation: pendingLocation,
targetHighlightRangeInfo: pendingHighlightRangeInfo,
highlights: highlights(for: page),
searchPresentation: searchPresentation(for: page)
)
@@ -97,4 +102,3 @@ extension RDEPUBReaderController {
}
}
}
@@ -91,8 +91,16 @@ extension RDEPUBReaderController {
///
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
runtime.restoreReadingLocation(location, animated: animated)
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
runtime.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
}
///
@@ -30,6 +30,7 @@ public final class RDEPUBReaderController: UIViewController {
public var configuration: RDEPUBReaderConfiguration {
didSet {
readerContext.configuration = configuration
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded()
guard isViewLoaded else { return }
let oldConfiguration = oldValue
@@ -230,12 +231,20 @@ public final class RDEPUBReaderController: UIViewController {
readerContext.epubURL = epubURL
readerContext.persistence = persistence
self.currentBrightness = brightness
applyWebViewDebugPolicy()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyWebViewDebugPolicy() {
RDEPUBWebViewDebug.applyDebugPolicy(
inspectableEnabled: configuration.allowsInspectableWebViews,
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
)
}
/// 使 TextBook EPUB
/// TXT TextBook
/// - Parameters:
@@ -318,9 +327,21 @@ public final class RDEPUBReaderController: UIViewController {
searchBarView.apply(theme: configuration.theme)
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.searchBarView.showSearching()
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchTextChanged = { [weak self] keyword in
guard let self else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedKeyword.isEmpty {
self.runtime.clearSearch()
} else {
self.searchBarView.showSearching()
self.runtime.search(keyword: normalizedKeyword)
}
self.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
@@ -329,6 +350,14 @@ public final class RDEPUBReaderController: UIViewController {
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onSelectMatch = { [weak self] matchIndex in
guard let self else { return }
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
self.updateSearchCount()
if didNavigate {
self.hideSearchBar(clearSearch: false)
}
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
@@ -345,22 +374,28 @@ public final class RDEPUBReaderController: UIViewController {
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
let bottomAnchor = bottomToolView.superview == nil
? readerView.bottomAnchor
: bottomToolView.topAnchor
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
searchBarView.heightAnchor.constraint(equalToConstant: 52)
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
UIView.animate(withDuration: 0.3) {
self.searchBarView.transform = .identity
searchBarView.alpha = 0
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
UIView.animate(withDuration: 0.28) {
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
} else {
searchBarView.showNoResults()
}
DispatchQueue.main.async { [weak self] in
@@ -375,11 +410,13 @@ public final class RDEPUBReaderController: UIViewController {
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.3, animations: {
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
UIView.animate(withDuration: 0.25, animations: {
self.searchBarView.alpha = 0
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.transform = .identity
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
self.readerView.searchBarView = nil
}
@@ -394,11 +431,11 @@ public final class RDEPUBReaderController: UIViewController {
searchBarView.showNoResults()
return
}
if let index = searchState.currentMatchIndex {
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
} else if searchState.matches.isEmpty {
searchBarView.showNoResults()
}
searchBarView.updateResults(
sections: searchResultSections(for: searchState),
keyword: searchState.keyword,
currentMatchIndex: searchState.currentMatchIndex
)
}
/// RDReaderView
@@ -417,3 +454,53 @@ public final class RDEPUBReaderController: UIViewController {
}
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
})
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
let title = searchSectionTitle(for: match)
if titles.last != title, titles.contains(title) == false {
titles.append(title)
}
}
return orderedTitles.compactMap { title in
guard let matches = groupedMatches[title] else { return nil }
let items = matches.map { offset, match in
RDEPUBReaderSearchSection.Item(
matchIndex: offset,
previewText: match.previewText,
isCurrent: offset == searchState.currentMatchIndex
)
}
return RDEPUBReaderSearchSection(title: title, items: items)
}
}
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
guard let publication else {
return match.href
}
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
if let tocItem = tocItems.last(where: {
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
return normalizedItemHref == normalizedMatchHref
}) {
return tocItem.title
}
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
publication.spine.indices.contains(spineIndex) {
return publication.spine[spineIndex].title
}
return normalizedMatchHref
}
}
@@ -64,6 +64,13 @@ public protocol RDEPUBReaderDelegate: AnyObject {
/// - url: URL
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// false
/// - Parameters:
/// - reader:
/// - url: URL
/// - Returns:
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
///
/// - Parameters:
/// - reader:
@@ -92,6 +99,7 @@ public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
}
@@ -39,23 +39,32 @@ public protocol RDEPUBReaderPersistence: AnyObject {
// MARK: -
/// //
/// DEBUG no-op 便
public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
_ = bookIdentifier
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
#endif
return []
}
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
_ = bookmarks
_ = bookIdentifier
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
#endif
}
func loadReaderSettings() -> RDEPUBReaderSettings? {
nil
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
#endif
return nil
}
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
_ = settings
#if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation")
#endif
}
}
@@ -136,6 +145,11 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
guard let data = try? JSONEncoder().encode(highlights) else {
return
}
if data.count > 1_048_576 {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif
}
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
}
@@ -1,47 +1,32 @@
import UIKit
// MARK: -
struct RDEPUBReaderSearchSection: Equatable {
struct Item: Equatable {
let matchIndex: Int
let previewText: String
let isCurrent: Bool
}
///
/// /
let title: String
let items: [Item]
}
// MARK: -
///
///
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
// MARK:
///
var onSearchSubmit: ((String) -> Void)?
///
var onSearchTextChanged: ((String) -> Void)?
var onSearchPrevious: (() -> Void)?
///
var onSearchNext: (() -> Void)?
///
var onSelectMatch: ((Int) -> Void)?
var onClose: (() -> Void)?
// MARK: UI
private let containerView: UIView = {
let view = UIView()
view.layer.cornerRadius = 8
view.layer.masksToBounds = true
view.isAccessibilityElement = false
view.accessibilityElementsHidden = false
return view
}()
private let searchIcon: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
if #available(iOS 13.0, *) {
imageView.image = UIImage(systemName: "magnifyingglass")
}
imageView.tintColor = .gray
return imageView
}()
let textField: UITextField = {
let field = UITextField()
field.placeholder = "搜索..."
field.font = UIFont.systemFont(ofSize: 15)
field.placeholder = "搜索"
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
field.returnKeyType = .search
field.autocorrectionType = .no
field.autocapitalizationType = .none
@@ -53,25 +38,25 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
return field
}()
private let backgroundButton = UIButton(type: .custom)
private let panelView = UIView()
private let grabberView = UIView()
private let searchRowView = UIView()
private let searchFieldContainer = UIView()
private let searchIcon = UIImageView()
private let searchFieldDivider = UIView()
private let cancelButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyStateLabel = UILabel()
// Preserve legacy accessibility hooks used by existing tests and demo logic.
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel()
private let countLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
label.textAlignment = .center
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
private let closeButton = RDEPUBReaderTintButton(type: .system)
// MARK:
private let horizontalInset: CGFloat = 12
private let spacing: CGFloat = 6
private let containerHeight: CGFloat = 36
private var searchSections: [RDEPUBReaderSearchSection] = []
private var keyword = ""
private var currentMatchIndex: Int?
override init(frame: CGRect) {
super.init(frame: frame)
@@ -81,162 +66,331 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
setupSubviews()
setupConstraints()
setupActions()
updateNavigationEnabled(false)
updateLegacyNavigationEnabled(false)
showInitialState()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK:
override func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
.zero
}
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
backgroundColor = theme.toolBackgroundColor
containerView.backgroundColor = theme.toolControlBorderUnselectColor
searchIcon.tintColor = theme.toolControlTextColor
textField.textColor = theme.toolControlTextColor
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
let overlayColor = isDarkBackground
? UIColor(white: 0.12, alpha: 0.92)
: UIColor(white: 0.08, alpha: 0.82)
let panelColor = isDarkBackground
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
let rowColor = isDarkBackground
? UIColor(white: 0.18, alpha: 1)
: UIColor(white: 0.14, alpha: 0.96)
let cardColor = isDarkBackground
? UIColor(white: 0.12, alpha: 1)
: UIColor(white: 0.10, alpha: 0.98)
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
let textColor = UIColor(white: 0.96, alpha: 1)
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
backgroundColor = .clear
backgroundButton.backgroundColor = overlayColor
panelView.backgroundColor = panelColor
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
searchRowView.backgroundColor = rowColor
searchFieldContainer.backgroundColor = .clear
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
searchIcon.tintColor = secondaryTextColor
cancelButton.tintColor = textColor
cancelButton.setTitleColor(textColor, for: .normal)
textField.textColor = textColor
textField.tintColor = UIColor.systemBlue
textField.keyboardAppearance = isDarkBackground ? .dark : .default
textField.attributedPlaceholder = NSAttributedString(
string: "搜索...",
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
string: "搜索",
attributes: [.foregroundColor: secondaryTextColor]
)
countLabel.textColor = theme.toolControlTextColor
previousButton.tintColor = theme.toolControlTextColor
nextButton.tintColor = theme.toolControlTextColor
closeButton.tintColor = theme.toolControlTextColor
emptyStateLabel.textColor = secondaryTextColor
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
previousButton.tintColor = textColor
nextButton.tintColor = textColor
countLabel.textColor = textColor
countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
tableView.reloadData()
}
// MARK:
var presentedView: UIView {
panelView
}
///
func updateMatchCount(current: Int, total: Int) {
countLabel.text = "\(current)/\(total)"
updateNavigationEnabled(total > 0)
textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0)
}
///
func showNoResults() {
countLabel.text = "0/0"
updateNavigationEnabled(false)
currentMatchIndex = nil
updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
}
///
func showSearching() {
countLabel.text = "搜索中..."
updateNavigationEnabled(false)
updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "搜索中..."
}
///
func restoreKeyword(_ keyword: String) {
textField.text = keyword
self.keyword = keyword
}
// MARK:
func updateResults(
sections: [RDEPUBReaderSearchSection],
keyword: String,
currentMatchIndex: Int?
) {
self.keyword = keyword
self.searchSections = sections
self.currentMatchIndex = currentMatchIndex
let total = sections.reduce(0) { $0 + $1.items.count }
if let currentMatchIndex, total > 0 {
updateMatchCount(current: currentMatchIndex + 1, total: total)
} else {
updateMatchCount(current: 0, total: total)
}
if total == 0 {
showNoResults()
return
}
emptyStateLabel.isHidden = true
tableView.isHidden = false
tableView.reloadData()
scrollToCurrentMatchIfNeeded()
}
private func setupSubviews() {
addSubview(containerView)
containerView.addSubview(searchIcon)
containerView.addSubview(textField)
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
panelView.translatesAutoresizingMaskIntoConstraints = false
grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
searchIcon.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
cancelButton.translatesAutoresizingMaskIntoConstraints = false
tableView.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
previousButton.translatesAutoresizingMaskIntoConstraints = false
nextButton.translatesAutoresizingMaskIntoConstraints = false
countLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundButton)
addSubview(panelView)
panelView.addSubview(grabberView)
panelView.addSubview(searchRowView)
panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel)
searchRowView.addSubview(searchFieldContainer)
searchRowView.addSubview(searchFieldDivider)
searchRowView.addSubview(cancelButton)
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
// Legacy shims
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
addSubview(closeButton)
if #available(iOS 13.0, *) {
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
searchIcon.image = UIImage(systemName: "magnifyingglass")
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
} else {
previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal)
closeButton.setTitle("", for: .normal)
}
searchIcon.contentMode = .scaleAspectFit
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
panelView.layer.cornerRadius = 28
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
grabberView.layer.cornerRadius = 3
searchRowView.layer.cornerRadius = 22
searchFieldContainer.layer.cornerRadius = 22
searchRowView.clipsToBounds = true
cancelButton.setTitle("取消", for: .normal)
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
emptyStateLabel.textAlignment = .center
emptyStateLabel.numberOfLines = 0
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
nextButton.accessibilityIdentifier = "epub.reader.search.next"
closeButton.accessibilityIdentifier = "epub.reader.search.close"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
previousButton.alpha = 0.01
nextButton.alpha = 0.01
countLabel.alpha = 0.01
[previousButton, nextButton, closeButton].forEach { button in
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
button.tintColor = .black
button.setTitleColor(.black, for: .normal)
}
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
tableView.dataSource = self
tableView.delegate = self
tableView.showsVerticalScrollIndicator = false
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
}
private func setupConstraints() {
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
NSLayoutConstraint.activate([
//
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
//
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
searchIcon.widthAnchor.constraint(equalToConstant: 16),
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
//
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
grabberView.widthAnchor.constraint(equalToConstant: 92),
grabberView.heightAnchor.constraint(equalToConstant: 6),
//
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 32),
previousButton.heightAnchor.constraint(equalToConstant: 32),
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
searchRowView.heightAnchor.constraint(equalToConstant: 52),
//
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 32),
nextButton.heightAnchor.constraint(equalToConstant: 32),
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
//
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
searchIcon.widthAnchor.constraint(equalToConstant: 24),
searchIcon.heightAnchor.constraint(equalToConstant: 24),
//
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32)
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
previousButton.topAnchor.constraint(equalTo: topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
])
}
private func setupActions() {
textField.delegate = self
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
}
private func updateNavigationEnabled(_ enabled: Bool) {
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled
previousButton.alpha = enabled ? 1 : 0.45
nextButton.isEnabled = enabled
nextButton.alpha = enabled ? 1 : 0.45
}
private func showInitialState() {
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "输入关键词开始搜索"
}
private func scrollToCurrentMatchIfNeeded() {
guard let currentMatchIndex else { return }
for (sectionIndex, section) in searchSections.enumerated() {
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
DispatchQueue.main.async { [weak self] in
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
return
}
}
}
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
searchSections[indexPath.section].items[indexPath.row]
}
@objc private func textFieldDidReturn() {
guard let keyword = textField.text, !keyword.isEmpty else { return }
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !keyword.isEmpty else { return }
onSearchSubmit?(keyword)
textField.resignFirstResponder()
}
@objc private func textFieldDidChange() {
guard textField.markedTextRange == nil else { return }
onSearchTextChanged?(textField.text ?? "")
}
@objc private func previousAction() {
onSearchPrevious?()
}
@@ -249,3 +403,197 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
onClose?()
}
}
private extension UIColor {
var rd_searchIsDarkBackground: Bool {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return false
}
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
return luminance < 0.5
}
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
searchSections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchSections[section].items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
for: indexPath
)
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
return cell
}
let item = item(at: indexPath)
cell.configure(
previewText: item.previewText,
keyword: keyword,
isCurrent: item.isCurrent
)
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let container = UIView()
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
label.textColor = UIColor(white: 0.96, alpha: 1)
label.text = searchSections[section].title
label.numberOfLines = 2
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
])
return container
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
40
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
116
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
onSelectMatch?(item(at: indexPath).matchIndex)
}
}
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
func textFieldShouldClear(_ textField: UITextField) -> Bool {
DispatchQueue.main.async { [weak self] in
self?.onSearchTextChanged?("")
}
return true
}
}
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
static var highlightTextColor = UIColor.systemBlue
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
private let cardView = UIView()
private let previewLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupSubviews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewLabel.attributedText = nil
}
func configure(previewText: String, keyword: String, isCurrent: Bool) {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
previewLabel.attributedText = attributedPreviewText(
previewText,
keyword: keyword,
isCurrent: isCurrent
)
}
private func setupSubviews() {
cardView.translatesAutoresizingMaskIntoConstraints = false
previewLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(cardView)
cardView.addSubview(previewLabel)
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
previewLabel.numberOfLines = 0
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
])
}
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
let normalizedText = previewText
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 8
let attributed = NSMutableAttributedString(
string: normalizedText,
attributes: [
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
.foregroundColor: Self.primaryTextColor,
.paragraphStyle: paragraphStyle
]
)
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !searchKeyword.isEmpty else { return attributed }
let nsText = normalizedText as NSString
var searchRange = NSRange(location: 0, length: nsText.length)
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
while searchRange.length > 0 {
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
let nextLocation = foundRange.location + max(foundRange.length, 1)
guard nextLocation < nsText.length else { break }
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
}
return attributed
}
}
@@ -136,5 +136,6 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
} else {
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
}
bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
}
}
@@ -54,6 +54,9 @@ public final class RDURLReaderController: UIViewController {
private var demoStateTimer: Timer?
private var lastEmittedDemoState = ""
private var pendingSearchKeyword: String?
private var externalLinkActivationCount = 0
private var lastActivatedExternalURL: URL?
private var lastReaderErrorDescription = "none"
///
/// - Parameters:
@@ -78,6 +81,7 @@ public final class RDURLReaderController: UIViewController {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = bookURL.deletingPathExtension().lastPathComponent
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
embedReaderController()
installDemoStateLabel()
}
@@ -370,6 +374,10 @@ public final class RDURLReaderController: UIViewController {
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let mapSnapshot = demoPaginationSnapshot()
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
?? (fileCount: 0, totalBytes: 0)
let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
let state = [
"reader=opened",
"page=\(page)",
@@ -389,7 +397,17 @@ public final class RDURLReaderController: UIViewController {
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)"
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
"inspectable=\(inspectable ? 1 : 0)",
"streamedResources=\(resourceMetrics.streamedResponses)",
"inMemoryResources=\(resourceMetrics.inMemoryResponses)",
"resourceFailures=\(resourceMetrics.failures)",
"cacheFiles=\(cacheStats.fileCount)",
"cacheBytes=\(cacheStats.totalBytes)",
"externalLinks=\(externalLinkActivationCount)",
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))"
].joined(separator: " ")
demoStateLabel.text = state
if let logPrefix {
@@ -405,6 +423,25 @@ public final class RDURLReaderController: UIViewController {
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
}
private func encodedDemoField(_ value: String?) -> String {
guard let value, !value.isEmpty else { return "nil" }
return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
}
private func currentSearchMatchText() -> String {
guard let match = readerController?.searchState?.currentMatch,
let rangeLocation = match.rangeLocation,
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
return "none"
}
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
guard nsRange.location >= 0,
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
return "none"
}
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
}
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
guard let readerController else {
return ("unavailable", "none", 0, 0, 0)
@@ -486,6 +523,17 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
}
public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
externalLinkActivationCount += 1
lastActivatedExternalURL = url
emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
}
public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
lastReaderErrorDescription = String(describing: error)
emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
}
}
/// Demo
@@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
//
} else {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
}
return nil
}
do {
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
return nil
}
}
// MARK: - BookPageMap
@@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
return true
}
/// renderSignature
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
isCacheComplete(keys: keys)
}
///
func removeAll() {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" {
try? fileManager.removeItem(at: fileURL)
removeFiles(matching: { _ in true })
}
///
func removeAll(forBookID bookID: String) {
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
removeFiles { $0.hasPrefix(bookPrefix + "__") }
}
///
func removeAll(forRenderSignature renderSignature: String) {
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
removeFiles { $0.contains(renderPrefix) }
}
///
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
return (0, 0)
}
var count = 0
var totalBytes: Int64 = 0
for fileURL in files where fileURL.pathExtension == "json" {
count += 1
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalBytes += Int64(size)
}
}
return (count, totalBytes)
}
// MARK: - key ->
/// 使 Hashable.hashValue
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.sha256Hex
return cacheDirectory.appendingPathComponent("\(digest).json")
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
}
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
let fileURL = self.fileURL(for: key)
let data = try? JSONEncoder().encode(summary)
try? data?.write(to: fileURL)
let tmpURL = fileURL.appendingPathExtension("tmp")
do {
let data = try JSONEncoder().encode(summary)
try data.write(to: tmpURL)
if fileManager.fileExists(atPath: fileURL.path) {
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
} else {
try fileManager.moveItem(at: tmpURL, to: fileURL)
}
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
try? fileManager.removeItem(at: tmpURL)
}
}
private func removeFiles(matching predicate: (String) -> Bool) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
try? fileManager.removeItem(at: fileURL)
}
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.sha256Hex.prefix(12).lowercased()
}
}
@@ -52,7 +52,9 @@ final class RDEPUBChapterWindowCoordinator {
self.isSwitchingChapter = false
self.buildSnapshotAroundCurrent(chapter: chapter)
case .failure(let error):
#if DEBUG
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
#endif
// / linear=false spine
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
@@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
#if DEBUG
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
#endif
return
}
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
@@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
return store.chapterData(for: spineIndex)
}
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
#if DEBUG
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
#endif
currentSnapshot = snapshot
isApplyingSnapshot = true
onSnapshotChanged?(snapshot)
@@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
private func handle(error: Error) {
//
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
#endif
// loading
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
//
if self.currentSnapshot == nil {
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
#endif
}
}
}
@@ -1,111 +0,0 @@
import Foundation
struct RDEPUBLocationConverter {
// MARK: -
/// RDEPUBLocation -> RDEPUBChapterLocation
/// chapterLength
/// fallback
static func convert(
legacy location: RDEPUBLocation,
parser: RDEPUBParser,
publication: RDEPUBPublication,
chapterLengthProvider: ((Int) -> Int?)? = nil
) -> RDEPUBChapterLocation? {
// 1. href spineIndex
guard let spineItem = publication.spine.first(where: {
$0.href == location.href || $0.href.contains(location.href)
}) else { return nil }
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
// 2. fragmentID progression
if let fragmentID = location.fragment {
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: 0, // fragmentID chapterOffsetMap
fragmentID: fragmentID,
progressionInChapter: location.progression
)
}
// 3. chapterLength
if let provider = chapterLengthProvider,
let chapterLength = provider(spineIndex), chapterLength > 0 {
return convert(
legacy: location,
spineIndex: spineIndex,
chapterLength: chapterLength
)
}
// 4. Fallback
let estimatedOffset = Int(location.progression * 10000)
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: estimatedOffset,
fragmentID: nil,
progressionInChapter: location.progression,
schemaVersion: 1 //
)
}
///
static func convert(
legacy location: RDEPUBLocation,
spineIndex: Int,
chapterLength: Int
) -> RDEPUBChapterLocation? {
let offset = Int(location.progression * Double(chapterLength))
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: offset,
fragmentID: location.fragment,
progressionInChapter: location.progression,
schemaVersion: 2
)
}
/// RDEPUBRuntimeChapter
static func convert(
legacy location: RDEPUBLocation,
chapter: RDEPUBRuntimeChapter
) -> RDEPUBChapterLocation? {
// fragmentID
if let fragmentID = location.fragment,
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
return RDEPUBChapterLocation(
spineIndex: chapter.spineIndex,
chapterOffset: fragmentOffset,
fragmentID: fragmentID,
progressionInChapter: nil,
schemaVersion: 2
)
}
// progression +
let chapterLength = chapter.typesetAttributedString.length
return convert(
legacy: location,
spineIndex: chapter.spineIndex,
chapterLength: chapterLength
)
}
/// ->
static func toLegacy(
chapterLocation: RDEPUBChapterLocation,
href: String,
chapterLength: Int
) -> RDEPUBLocation {
let progression = chapterLength > 0
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
: 0
return RDEPUBLocation(
href: href,
progression: min(max(progression, 0), 1),
fragment: chapterLocation.fragmentID
)
}
}
@@ -17,12 +17,6 @@ final class RDEPUBPageCountCache {
}
}
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
lock.lock()
defer { lock.unlock() }
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
}
func remove(forSpineIndex spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
@@ -32,14 +32,34 @@ final class RDEPUBReaderAnnotationCoordinator {
///
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
if let selection, !selection.isEmpty {
applySelectionState(.selected(selection))
} else {
applySelectionState(.idle)
}
}
///
/// `.selected` context chrome delegate
/// `.idle` chrome delegate
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
controller.currentSelection = selection?.isEmpty == false ? selection : nil
if controller.currentSelection != nil,
controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
context.selectionState = state
switch state {
case .idle:
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: nil)
case .selecting:
break
case .selected(let selection):
if controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
break
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
}
///
@@ -139,11 +159,10 @@ final class RDEPUBReaderAnnotationCoordinator {
///
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
guard let highlight = highlight(withID: id) else {
return false
}
return controller.restoreReadingLocation(highlight.location, animated: animated)
return navigate(to: highlight, animated: animated)
}
///
@@ -196,9 +215,8 @@ final class RDEPUBReaderAnnotationCoordinator {
}
)
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
guard let controller = self?.controller else { return }
highlightsController?.dismiss(animated: true) {
controller.go(to: highlight.location)
_ = self?.navigate(to: highlight, animated: true)
}
}
highlightsController.onUpdateHighlight = { [weak self] highlight in
@@ -371,6 +389,17 @@ final class RDEPUBReaderAnnotationCoordinator {
)
}
@discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false }
let navigationTarget = scopedHighlight(highlight) ?? highlight
return controller.restoreReadingLocation(
navigationTarget.location,
animated: animated,
targetHighlightRangeInfo: navigationTarget.rangeInfo
)
}
private func persistHighlightsAndRefreshContent() {
guard let controller else { return }
if let currentBookIdentifier = controller.currentBookIdentifier {
@@ -23,7 +23,9 @@ final class RDEPUBReaderAssemblyCoordinator {
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
#if DEBUG
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
#endif
}
///
@@ -41,9 +43,13 @@ final class RDEPUBReaderAssemblyCoordinator {
}
if let textBook = controller.textBook {
#if DEBUG
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
#endif
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
#if DEBUG
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
#endif
} else {
runtime.finishPagination(restoreLocation: restoreLocation)
}
@@ -70,7 +70,7 @@ final class RDEPUBReaderChromeCoordinator {
canToggleBookmark: controller.currentBookIdentifier != nil,
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
canShowBookmarks: !controller.activeBookmarks.isEmpty,
canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil,
canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights,
@@ -59,8 +59,19 @@ final class RDEPUBReaderContext {
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
///
var currentSelection: RDEPUBSelection?
/// selectionState
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
}
///
var selectionState: RDEPUBSelectionState = .idle
// MARK: - controller
@@ -16,7 +16,11 @@ final class RDEPUBReaderLocationCoordinator {
///
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
guard let controller = context.controller,
let readerView = context.readerView else { return false }
guard let targetPageNumber = controller.pageNumber(for: location) else {
@@ -32,13 +36,15 @@ final class RDEPUBReaderLocationCoordinator {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier
bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else if context.textBook == nil {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier
bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else {
context.readingSession?.transition(to: .jumping)
@@ -33,10 +33,14 @@ final class RDEPUBReaderPaginationCoordinator {
controller.showLoading()
let token = UUID()
context.paginationToken = token
#if DEBUG
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
#endif
if publication.readingProfile == .textReflowable {
#if DEBUG
print("[EPUB][Pagination] path=text-reflowable-on-demand")
#endif
paginateTextPublication(
parser: parser,
publication: publication,
@@ -48,7 +52,9 @@ final class RDEPUBReaderPaginationCoordinator {
}
if publication.layout == .fixed {
#if DEBUG
print("[EPUB][Pagination] path=fixed-layout")
#endif
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(),
@@ -59,7 +65,9 @@ final class RDEPUBReaderPaginationCoordinator {
}
let paginator = context.makePaginator()
#if DEBUG
print("[EPUB][Pagination] path=web-paginator")
#endif
context.paginator = paginator
paginator.calculate(
parser: parser,
@@ -230,6 +230,12 @@ final class RDEPUBReaderRuntime {
searchCoordinator.searchPrevious()
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
searchCoordinator.selectSearchMatch(at: index)
}
///
func clearSearch() {
searchCoordinator.clearSearch()
@@ -365,8 +371,16 @@ final class RDEPUBReaderRuntime {
///
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
locationCoordinator.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
}
///
@@ -51,6 +51,21 @@ final class RDEPUBReaderSearchCoordinator {
advanceSearch(by: -1)
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState,
searchState.matches.indices.contains(index) else {
return false
}
searchState.currentMatchIndex = index
controller.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
///
func clearSearch() {
guard let controller else { return }
@@ -109,12 +124,109 @@ final class RDEPUBReaderSearchCoordinator {
}
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
}
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
return resolvedOnDemandSearchMatches(for: keyword)
}
if let parser = controller.parser, let publication = controller.publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
}
return []
}
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller,
let publication = controller.publication else {
return []
}
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return []
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var matches: [RDEPUBSearchMatch] = []
for spineIndex in buildableSpineIndices {
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: controller.runtime.chapterRuntimeStore
) else {
continue
}
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
let source = chapter.typesetAttributedString.string as NSString
let fullLength = source.length
guard fullLength > 0 else { continue }
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else {
break
}
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
}
return matches
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func advanceSearch(by delta: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
@@ -162,6 +274,14 @@ final class RDEPUBReaderSearchCoordinator {
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let controller else { return nil }
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
if let exactPageNumber = exactPageNumber(
for: searchMatch,
in: chapterData,
keyword: controller.searchState?.keyword
) {
return exactPageNumber
}
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber
}
@@ -194,4 +314,39 @@ final class RDEPUBReaderSearchCoordinator {
bookIdentifier: controller.currentBookIdentifier
).map { $0 + 1 }
}
private func exactPageNumber(
for searchMatch: RDEPUBSearchMatch,
in chapterData: RDEPUBChapterData,
keyword: String?
) -> Int? {
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalizedKeyword.isEmpty else { return nil }
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return nil }
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
if localMatchIndex == searchMatch.localMatchIndex,
let page = chapterData.page(containing: foundRange.location) {
return page.absolutePageIndex + 1
}
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return nil
}
}
@@ -0,0 +1,39 @@
// RDEPUBSelectionState.swift
//
// view controller
//
import Foundation
///
/// view/controller/coordinator `currentSelection != nil`
enum RDEPUBSelectionState: Equatable {
///
case idle
///
case selecting(anchor: Int)
///
case selected(RDEPUBSelection)
/// // idle
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
/// selecting / selected / committingAction
var hasSelection: Bool {
switch self {
case .idle:
return false
case .selecting, .selected, .committingAction:
return true
}
}
///
var selection: RDEPUBSelection? {
switch self {
case .idle, .selecting:
return nil
case .selected(let selection), .committingAction(let selection, _):
return selection
}
}
}
@@ -114,6 +114,17 @@ public struct RDEPUBReaderConfiguration: Equatable {
/// writeTotalMs I/O cpuCount * 1.25~1.5 I/O
public var metadataParsingConcurrency: Int
// MARK:
/// URL scheme https
public var allowedExternalURLSchemes: Set<String>
/// true
public var requiresExternalLinkConfirmation: Bool
/// WebView inspectable false
public var allowsInspectableWebViews: Bool
/// WebView false
public var enablesVerboseWebViewLogging: Bool
// MARK:
///
@@ -136,6 +147,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
/// - textRenderingEngine:
/// - onDemandChapterWindowSize: 3...15
/// - metadataParsingConcurrency: CPU
/// - allowedExternalURLSchemes: URL scheme https
/// - requiresExternalLinkConfirmation: true
/// - allowsInspectableWebViews: inspectable false
/// - enablesVerboseWebViewLogging: WebView false
public init(
fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6,
@@ -156,7 +171,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
onDemandChapterWindowSize: Int = 3,
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
allowedExternalURLSchemes: Set<String> = ["https"],
requiresExternalLinkConfirmation: Bool = true,
allowsInspectableWebViews: Bool = false,
enablesVerboseWebViewLogging: Bool = false
) {
self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple
@@ -178,6 +197,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.textRenderingEngine = textRenderingEngine
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
self.allowedExternalURLSchemes = allowedExternalURLSchemes
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
self.allowsInspectableWebViews = allowsInspectableWebViews
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
}
/// 使
@@ -239,6 +239,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
updateThemeSelection(selectedPreset)
updateControlAccessibilityValues()
}
private func applyTheme(_ theme: RDEPUBReaderTheme) {
@@ -279,9 +280,17 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let isSelected = button.tag == preset.rawValue
button.layer.borderWidth = isSelected ? 2 : 1
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
button.accessibilityValue = isSelected ? "selected" : "unselected"
}
}
private func updateControlAccessibilityValues() {
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
}
@objc private func doneAction() {
dismiss(animated: true)
}
@@ -312,6 +321,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let choice = choices[index]
guard choice != currentConfiguration.fontChoice else { return }
currentConfiguration.fontChoice = choice
updateControlAccessibilityValues()
onFontChoiceChange?(choice)
}
@@ -319,12 +329,14 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
let value = lineHeightValues[index]
currentConfiguration.lineHeightMultiple = value
updateControlAccessibilityValues()
onLineHeightChange?(value)
}
@objc private func columnCountChanged(_ control: UISegmentedControl) {
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
currentConfiguration.numberOfColumns = numberOfColumns
updateControlAccessibilityValues()
onColumnCountChange?(numberOfColumns)
}
@@ -339,6 +351,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
displayType = .pageCurl
}
currentConfiguration.displayType = displayType
updateControlAccessibilityValues()
onDisplayTypeChange?(displayType)
}
@@ -2,6 +2,9 @@ import UIKit
///
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
///
/// - Parameters:
/// - highlights:
@@ -58,8 +61,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
) {
guard let searchState else { return }
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
@@ -72,7 +73,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor
let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
}
@@ -97,9 +98,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let pageEndExclusive = pageRange.upperBound
if let searchState {
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
for match in searchState.matches {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
@@ -113,7 +111,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let isActive = match == searchState.currentMatch
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
let color = isActive ? activeColor : normalColor
let color = isActive ? activeSearchColor : normalSearchColor
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
}
}
@@ -14,6 +14,11 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection?
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
@@ -33,6 +38,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private var currentSelection: RDEPUBSelection?
private var menuSelection: RDEPUBSelection?
private var currentHighlights: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
weak var delegate: RDEPUBTextContentViewDelegate?
#if canImport(DTCoreText)
@@ -188,6 +194,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentSelection = nil
menuSelection = nil
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
@@ -244,14 +251,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
highlights: [],
searchState: searchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif
updateAccessibilityDecorationSummary()
@@ -262,6 +261,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
func clearSelection() {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
panGestureRecognizer.isEnabled = false
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
@@ -487,6 +487,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
interactionController.configure(layoutFrame: layoutFrame, page: page)
overlayView.updateSnapshot(interactionController.snapshot)
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
if let page = currentPage {
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
highlights: [],
searchState: currentSearchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
}
}
#endif
@@ -539,6 +550,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
clearSelection()
return
}
if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
delegate?.textContentView(self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect)
return
}
guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
return
@@ -621,6 +637,41 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return overlayView.convert(fallbackRect, to: self)
}
private func attachmentText(at point: CGPoint) -> String? {
guard let page = currentPage else { return nil }
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
return nil
}
guard page.chapterContent.length > attachmentRange.location else {
return nil
}
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment,
let altText = (textAttachment.attributes["alt"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
#endif
if let fileAttachment = attachment as? NSTextAttachment,
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
return nil
}
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
return overlayView.convert(attachmentRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
return selectionController.isSelecting
@@ -633,7 +684,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return true
}
let point = tapGestureRecognizer.location(in: overlayView)
return highlight(at: point) != nil
return attachmentText(at: point) != nil || highlight(at: point) != nil
}
return true
}
@@ -140,12 +140,25 @@ final class RDEPUBTextSelectionController: NSObject {
let totalLength = max(page.chapterContent.length - 1, 1)
let globalStart = absoluteRange.location
let globalEnd = absoluteRange.location + absoluteRange.length
let startAnchor = RDEPUBTextAnchor(
fileIndex: page.spineIndex,
row: 0,
column: 0,
chapterOffset: globalStart
)
let endAnchor = RDEPUBTextAnchor(
fileIndex: page.spineIndex,
row: 0,
column: 0,
chapterOffset: globalEnd
)
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil
fragment: nil,
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()