源码注释: - 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法) - 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块 文档维护: - 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致) - 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值) - 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll - 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录 - 更新 index.md 索引:新增开发计划和架构对比文档引用
529 lines
22 KiB
Swift
529 lines
22 KiB
Swift
import UIKit
|
||
|
||
/// EPUB 阅读器标注协调器:负责高亮、批注和书签的增删改查操作。
|
||
///
|
||
/// 职责:
|
||
/// - 管理高亮(highlight)与批注(annotation)的创建、更新和删除
|
||
/// - 管理书签(bookmark)的添加、切换和删除
|
||
/// - 处理文本选中后的菜单操作(复制、高亮、批注)
|
||
/// - 弹出高亮管理器和书签管理器界面
|
||
final class RDEPUBReaderAnnotationCoordinator {
|
||
private unowned let context: RDEPUBReaderContext
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
private var controller: RDEPUBReaderController? {
|
||
context.controller
|
||
}
|
||
|
||
/// 根据 ID 查找书签。
|
||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||
guard let controller else { return nil }
|
||
return controller.activeBookmarks.first { $0.id == id }
|
||
}
|
||
|
||
/// 根据 ID 查找高亮。
|
||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||
guard let controller else { return nil }
|
||
return controller.activeHighlights.first { $0.id == id }
|
||
}
|
||
|
||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||
guard let controller else { return }
|
||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||
if controller.currentSelection != nil,
|
||
controller.readerView.isShowToolView == false {
|
||
controller.readerView.tapCenter()
|
||
}
|
||
controller.bottomToolView.setAddHighlightEnabled(
|
||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
||
)
|
||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
||
}
|
||
|
||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||
@discardableResult
|
||
func addHighlight(
|
||
from selection: RDEPUBSelection? = nil,
|
||
color: String = "#F8E16C",
|
||
note: String? = nil
|
||
) -> RDEPUBHighlight? {
|
||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||
}
|
||
|
||
/// 基于选区创建标注(高亮/划线/批注),支持指定样式、颜色和备注。
|
||
@discardableResult
|
||
func addAnnotation(
|
||
from selection: RDEPUBSelection? = nil,
|
||
style: RDEPUBHighlightStyle,
|
||
color: String = "#F8E16C",
|
||
note: String? = nil
|
||
) -> RDEPUBHighlight? {
|
||
guard let controller else { return nil }
|
||
let sourceSelection = selection ?? controller.currentSelection
|
||
guard let sourceSelection,
|
||
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
|
||
!scopedSelection.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
let newHighlight = RDEPUBHighlight(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
location: scopedSelection.location,
|
||
text: scopedSelection.text,
|
||
rangeInfo: scopedSelection.rangeInfo,
|
||
style: style,
|
||
color: color,
|
||
note: note
|
||
)
|
||
|
||
let isDuplicate = controller.activeHighlights.contains { highlight in
|
||
highlight.location.href == newHighlight.location.href &&
|
||
highlight.location.fragment == newHighlight.location.fragment &&
|
||
highlight.text == newHighlight.text &&
|
||
highlight.rangeInfo == newHighlight.rangeInfo &&
|
||
highlight.style == newHighlight.style
|
||
}
|
||
guard !isDuplicate else {
|
||
return nil
|
||
}
|
||
|
||
controller.activeHighlights.append(newHighlight)
|
||
persistHighlightsAndRefreshContent()
|
||
updateCurrentSelection(nil)
|
||
return newHighlight
|
||
}
|
||
|
||
/// 插入或更新高亮(upsert),按 ID 匹配已有记录。
|
||
@discardableResult
|
||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||
guard let controller else { return nil }
|
||
guard let scopedHighlight = scopedHighlight(highlight) else {
|
||
return nil
|
||
}
|
||
|
||
if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
|
||
controller.activeHighlights[index] = scopedHighlight
|
||
} else {
|
||
controller.activeHighlights.append(scopedHighlight)
|
||
}
|
||
persistHighlightsAndRefreshContent()
|
||
return scopedHighlight
|
||
}
|
||
|
||
/// 根据 ID 删除高亮并持久化。
|
||
@discardableResult
|
||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||
guard let controller else { return nil }
|
||
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
|
||
return nil
|
||
}
|
||
let removed = controller.activeHighlights.remove(at: index)
|
||
persistHighlightsAndRefreshContent()
|
||
return removed
|
||
}
|
||
|
||
/// 更新指定高亮的批注备注内容。
|
||
@discardableResult
|
||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||
guard let controller else { return nil }
|
||
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
|
||
return nil
|
||
}
|
||
controller.activeHighlights[index].note = normalizedNote(note)
|
||
persistHighlightsAndRefreshContent()
|
||
return controller.activeHighlights[index]
|
||
}
|
||
|
||
/// 跳转到指定高亮所在位置。
|
||
@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)
|
||
}
|
||
|
||
/// 清除所有高亮标记。
|
||
func removeAllHighlights() {
|
||
guard let controller else { return }
|
||
guard !controller.activeHighlights.isEmpty else { return }
|
||
controller.activeHighlights.removeAll()
|
||
persistHighlightsAndRefreshContent()
|
||
}
|
||
|
||
/// 将选区位置相对于指定 spine 索引进行规范化。
|
||
func scopedSelection(
|
||
_ selection: RDEPUBSelection,
|
||
relativeToSpineIndex spineIndex: Int?
|
||
) -> RDEPUBSelection? {
|
||
guard let controller else { return nil }
|
||
guard let publication = controller.publication else { return nil }
|
||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||
selection.location,
|
||
relativeToSpineIndex: spineIndex,
|
||
bookIdentifier: controller.currentBookIdentifier
|
||
) ?? RDEPUBLocation(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
href: selection.location.href,
|
||
progression: selection.location.progression,
|
||
lastProgression: selection.location.lastProgression,
|
||
fragment: selection.location.fragment,
|
||
rangeAnchor: selection.location.rangeAnchor
|
||
)
|
||
return RDEPUBSelection(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
location: normalizedLocation,
|
||
text: selection.text,
|
||
rangeInfo: selection.rangeInfo,
|
||
createdAt: selection.createdAt
|
||
)
|
||
}
|
||
|
||
/// 弹出高亮管理器,支持查看、编辑备注和删除高亮。
|
||
func presentHighlightsManager() {
|
||
guard let controller else { return }
|
||
guard controller.configuration.allowsHighlights else { return }
|
||
guard !controller.activeHighlights.isEmpty else { return }
|
||
|
||
let highlightsController = RDEPUBReaderHighlightsViewController(
|
||
highlights: controller.activeHighlights,
|
||
theme: controller.configuration.theme,
|
||
sectionTitleProvider: { [weak self] highlight in
|
||
self?.titleForHighlight(highlight)
|
||
}
|
||
)
|
||
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
|
||
guard let controller = self?.controller else { return }
|
||
highlightsController?.dismiss(animated: true) {
|
||
controller.go(to: highlight.location)
|
||
}
|
||
}
|
||
highlightsController.onUpdateHighlight = { [weak self] highlight in
|
||
_ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
|
||
}
|
||
highlightsController.onDeleteHighlight = { [weak self] highlight in
|
||
_ = self?.controller?.removeHighlight(id: highlight.id)
|
||
}
|
||
|
||
let navigationController = UINavigationController(rootViewController: highlightsController)
|
||
navigationController.modalPresentationStyle = .pageSheet
|
||
controller.present(navigationController, animated: true)
|
||
}
|
||
|
||
/// 弹出标注创建面板(高亮/划线/批注选择)。
|
||
func presentAnnotationCreation() {
|
||
guard let controller else { return }
|
||
guard controller.configuration.allowsHighlights,
|
||
let currentSelection = controller.currentSelection else {
|
||
return
|
||
}
|
||
presentAnnotationActionSheet(for: currentSelection)
|
||
}
|
||
|
||
/// 处理文本选中后的菜单操作:复制、高亮、批注。
|
||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||
guard let selection else { return }
|
||
switch action {
|
||
case .copy:
|
||
UIPasteboard.general.string = selection.text
|
||
updateCurrentSelection(nil)
|
||
case .highlight:
|
||
createAnnotation(from: selection, style: .highlight)
|
||
case .annotate:
|
||
presentAnnotationNoteEditor(for: selection)
|
||
}
|
||
}
|
||
|
||
/// 在当前位置添加书签,自动去重。
|
||
@discardableResult
|
||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||
guard let controller else { return nil }
|
||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||
return nil
|
||
}
|
||
guard bookmark(matching: location) == nil else {
|
||
return nil
|
||
}
|
||
|
||
let newBookmark = RDEPUBBookmark(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
location: location,
|
||
chapterTitle: titleForBookmarkLocation(location),
|
||
note: normalizedBookmarkNote(note)
|
||
)
|
||
controller.activeBookmarks.append(newBookmark)
|
||
persistBookmarksAndRefreshChrome()
|
||
return newBookmark
|
||
}
|
||
|
||
/// 切换当前位置的书签状态:已存在则移除,不存在则添加。
|
||
@discardableResult
|
||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||
guard let controller else { return nil }
|
||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||
return nil
|
||
}
|
||
|
||
if let existingBookmark = bookmark(matching: location) {
|
||
_ = removeBookmark(id: existingBookmark.id)
|
||
return nil
|
||
}
|
||
|
||
return addBookmark(note: note)
|
||
}
|
||
|
||
/// 根据 ID 删除书签并持久化。
|
||
@discardableResult
|
||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||
guard let controller else { return nil }
|
||
guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
|
||
return nil
|
||
}
|
||
let removed = controller.activeBookmarks.remove(at: index)
|
||
persistBookmarksAndRefreshChrome()
|
||
return removed
|
||
}
|
||
|
||
/// 跳转到指定书签所在位置。
|
||
@discardableResult
|
||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||
guard let controller else { return false }
|
||
guard let bookmark = bookmark(withID: id) else {
|
||
return false
|
||
}
|
||
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
||
}
|
||
|
||
/// 同步顶部书签按钮和底部书签列表按钮的 UI 状态。
|
||
func updateBookmarkChrome() {
|
||
guard let controller else { return }
|
||
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
|
||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
||
}
|
||
|
||
/// 弹出书签管理器,支持查看、跳转和删除书签。
|
||
func presentBookmarksManager() {
|
||
guard let controller else { return }
|
||
guard !controller.activeBookmarks.isEmpty else { return }
|
||
|
||
let bookmarksController = RDEPUBReaderBookmarksViewController(
|
||
bookmarks: controller.activeBookmarks,
|
||
theme: controller.configuration.theme
|
||
)
|
||
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
|
||
guard let controller = self?.controller else { return }
|
||
bookmarksController?.dismiss(animated: true) {
|
||
_ = controller.restoreReadingLocation(bookmark.location, animated: true)
|
||
}
|
||
}
|
||
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
|
||
_ = self?.controller?.removeBookmark(id: bookmark.id)
|
||
}
|
||
|
||
let navigationController = UINavigationController(rootViewController: bookmarksController)
|
||
navigationController.modalPresentationStyle = .pageSheet
|
||
controller.present(navigationController, animated: true)
|
||
}
|
||
|
||
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||
guard let controller else { return nil }
|
||
guard let publication = controller.publication else { return nil }
|
||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||
highlight.location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: controller.currentBookIdentifier
|
||
) ?? RDEPUBLocation(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
href: highlight.location.href,
|
||
progression: highlight.location.progression,
|
||
lastProgression: highlight.location.lastProgression,
|
||
fragment: highlight.location.fragment,
|
||
rangeAnchor: highlight.location.rangeAnchor
|
||
)
|
||
return RDEPUBHighlight(
|
||
id: highlight.id,
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
location: normalizedLocation,
|
||
text: highlight.text,
|
||
rangeInfo: highlight.rangeInfo,
|
||
style: highlight.style,
|
||
color: highlight.color,
|
||
note: highlight.note,
|
||
createdAt: highlight.createdAt
|
||
)
|
||
}
|
||
|
||
private func persistHighlightsAndRefreshContent() {
|
||
guard let controller else { return }
|
||
if let currentBookIdentifier = controller.currentBookIdentifier {
|
||
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
|
||
}
|
||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||
controller.updateReaderChrome()
|
||
controller.refreshVisibleContentPreservingLocation()
|
||
}
|
||
|
||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||
guard let controller else { return }
|
||
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
||
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
|
||
self?.createAnnotation(from: selection, style: .highlight)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
||
self?.createAnnotation(from: selection, style: .underline)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
|
||
self?.presentAnnotationNoteEditor(for: selection)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
|
||
if let popover = alert.popoverPresentationController {
|
||
popover.sourceView = controller.bottomToolView
|
||
popover.sourceRect = controller.bottomToolView.bounds
|
||
}
|
||
|
||
controller.present(alert, animated: true)
|
||
}
|
||
|
||
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
|
||
_ = addAnnotation(from: selection, style: style, note: note)
|
||
}
|
||
|
||
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
||
guard let controller else { return }
|
||
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
|
||
alert.addTextField { textField in
|
||
textField.placeholder = "输入批注内容"
|
||
}
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||
self?.createAnnotation(
|
||
from: selection,
|
||
style: .highlight,
|
||
note: alert?.textFields?.first?.text
|
||
)
|
||
})
|
||
controller.present(alert, animated: true)
|
||
}
|
||
|
||
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
||
guard let controller else { return nil }
|
||
guard let publication = controller.publication,
|
||
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
|
||
return nil
|
||
}
|
||
|
||
return controller.flattenedTableOfContents.first { item in
|
||
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
|
||
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
|
||
}?.title
|
||
}
|
||
|
||
private func normalizedNote(_ note: String?) -> String? {
|
||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
|
||
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
|
||
guard let controller else { return nil }
|
||
if let currentLocation = controller.currentVisibleLocation(),
|
||
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
|
||
return controller.currentTableOfContentsItem?.title
|
||
}
|
||
|
||
return controller.flattenedTableOfContents.last { item in
|
||
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
|
||
}?.title
|
||
}
|
||
|
||
private func persistBookmarksAndRefreshChrome() {
|
||
guard let controller else { return }
|
||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
|
||
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
|
||
updateBookmarkChrome()
|
||
}
|
||
|
||
private func currentBookmark() -> RDEPUBBookmark? {
|
||
guard let controller else { return nil }
|
||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||
return nil
|
||
}
|
||
return bookmark(matching: location)
|
||
}
|
||
|
||
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
|
||
guard let controller else { return nil }
|
||
guard let location else { return nil }
|
||
return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
|
||
}
|
||
|
||
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
|
||
guard let controller else { return false }
|
||
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
|
||
return false
|
||
}
|
||
|
||
if let bookmarkAnchor = bookmark.location.rangeAnchor,
|
||
let locationAnchor = location.rangeAnchor {
|
||
return bookmarkAnchor == locationAnchor
|
||
}
|
||
|
||
if let bookmarkFragment = bookmark.location.fragment,
|
||
let locationFragment = location.fragment {
|
||
return bookmarkFragment == locationFragment
|
||
}
|
||
|
||
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
|
||
let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05
|
||
return progressionDelta <= threshold
|
||
}
|
||
|
||
private func bookmarkHref(for location: RDEPUBLocation) -> String {
|
||
controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
|
||
}
|
||
|
||
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
|
||
let rawHref = href.components(separatedBy: "#").first ?? href
|
||
return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
|
||
}
|
||
|
||
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
|
||
guard let controller else { return nil }
|
||
guard let location else { return nil }
|
||
guard let publication = controller.publication else {
|
||
return RDEPUBLocation(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
href: location.href,
|
||
progression: location.progression,
|
||
lastProgression: location.lastProgression,
|
||
fragment: location.fragment,
|
||
rangeAnchor: location.rangeAnchor
|
||
)
|
||
}
|
||
|
||
return publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: controller.currentBookIdentifier
|
||
) ?? RDEPUBLocation(
|
||
bookIdentifier: controller.currentBookIdentifier,
|
||
href: location.href,
|
||
progression: location.progression,
|
||
lastProgression: location.lastProgression,
|
||
fragment: location.fragment,
|
||
rangeAnchor: location.rangeAnchor
|
||
)
|
||
}
|
||
|
||
private func normalizedBookmarkNote(_ note: String?) -> String? {
|
||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
}
|