- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建 与 staged 合并策略,避免整书一次性构建的主线程阻塞 - TextBookBuilder: 重构为支持单章构建与增量合并模式 - ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存 - PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能 - ReaderContext/ReaderRuntime: 适配增量分页状态管理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
353 lines
14 KiB
Swift
353 lines
14 KiB
Swift
import UIKit
|
||
|
||
// MARK: - 高亮管理面板
|
||
|
||
/// EPUB 阅读器的高亮标注管理控制器
|
||
/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除
|
||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||
/// 用户选择跳转到某个高亮位置时的回调
|
||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||
/// 用户编辑高亮批注后的回调
|
||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||
/// 用户删除高亮时的回调
|
||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||
|
||
/// 高亮列表(按创建时间降序排列)
|
||
private var highlights: [RDEPUBHighlight]
|
||
/// 当前主题配置
|
||
private let theme: RDEPUBReaderTheme
|
||
/// 提供章节标题的回调,用于显示高亮所在的章节名
|
||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||
/// 筛选控件:全部、批注、高亮
|
||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||
|
||
private var filteredHighlights: [RDEPUBHighlight] {
|
||
switch filterControl.selectedSegmentIndex {
|
||
case 1:
|
||
return highlights.filter(\.hasNote)
|
||
case 2:
|
||
return highlights.filter { $0.style == .highlight }
|
||
default:
|
||
return highlights
|
||
}
|
||
}
|
||
|
||
init(
|
||
highlights: [RDEPUBHighlight],
|
||
theme: RDEPUBReaderTheme,
|
||
sectionTitleProvider: @escaping (RDEPUBHighlight) -> String?
|
||
) {
|
||
self.highlights = highlights.sorted { $0.createdAt > $1.createdAt }
|
||
self.theme = theme
|
||
self.sectionTitleProvider = sectionTitleProvider
|
||
super.init(style: .plain)
|
||
title = "标注"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
tableView.tableFooterView = UIView(frame: .zero)
|
||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||
configureFilterControl()
|
||
applyTheme()
|
||
updateEmptyState()
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
filteredHighlights.count
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
let cellIdentifier = "HighlightCell"
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||
let highlight = filteredHighlights[indexPath.row]
|
||
|
||
cell.backgroundColor = theme.contentBackgroundColor
|
||
cell.textLabel?.textColor = theme.contentTextColor
|
||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||
cell.textLabel?.numberOfLines = 2
|
||
cell.textLabel?.text = titleText(for: highlight)
|
||
|
||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||
cell.detailTextLabel?.numberOfLines = 3
|
||
cell.detailTextLabel?.text = detailText(for: highlight)
|
||
cell.accessoryType = .disclosureIndicator
|
||
return cell
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
|
||
}
|
||
|
||
private func configureFilterControl() {
|
||
filterControl.selectedSegmentIndex = 0
|
||
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
|
||
navigationItem.titleView = filterControl
|
||
}
|
||
|
||
private func applyTheme() {
|
||
tableView.backgroundColor = theme.contentBackgroundColor
|
||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||
}
|
||
|
||
private func updateEmptyState() {
|
||
guard filteredHighlights.isEmpty else {
|
||
tableView.backgroundView = nil
|
||
return
|
||
}
|
||
|
||
let label = UILabel()
|
||
label.text = emptyStateText()
|
||
label.textAlignment = .center
|
||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||
label.numberOfLines = 0
|
||
tableView.backgroundView = label
|
||
}
|
||
|
||
@objc private func filterChangedAction() {
|
||
tableView.reloadData()
|
||
updateEmptyState()
|
||
}
|
||
|
||
private func emptyStateText() -> String {
|
||
switch filterControl.selectedSegmentIndex {
|
||
case 1:
|
||
return "暂无批注"
|
||
case 2:
|
||
return "暂无划线"
|
||
default:
|
||
return "暂无标注"
|
||
}
|
||
}
|
||
|
||
private func detailText(for highlight: RDEPUBHighlight) -> String {
|
||
let style = styleDescription(for: highlight)
|
||
let chapter = sectionTitleProvider(highlight)
|
||
let note = normalizedNote(highlight.note)
|
||
let parts = [style, chapter, note].compactMap { $0 }
|
||
if !parts.isEmpty {
|
||
return parts.joined(separator: "\n")
|
||
}
|
||
return highlight.location.href
|
||
}
|
||
|
||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if highlight.hasNote {
|
||
return "批注: \(text)"
|
||
}
|
||
return text
|
||
}
|
||
|
||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||
switch highlight.style {
|
||
case .highlight:
|
||
return highlight.hasNote ? "高亮批注" : "高亮"
|
||
case .underline:
|
||
return highlight.hasNote ? "划线批注" : "划线"
|
||
}
|
||
}
|
||
|
||
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
|
||
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
|
||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
||
self?.onSelectHighlight?(highlight)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
|
||
self?.presentNoteEditor(for: highlight)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
|
||
self?.deleteHighlight(highlight)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
|
||
if let popover = alert.popoverPresentationController,
|
||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
||
popover.sourceView = cell
|
||
popover.sourceRect = cell.bounds
|
||
}
|
||
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
|
||
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
|
||
alert.addTextField { textField in
|
||
textField.placeholder = "输入批注内容"
|
||
textField.text = highlight.note
|
||
}
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||
guard let self,
|
||
let note = alert?.textFields?.first?.text,
|
||
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
|
||
return
|
||
}
|
||
|
||
var updated = highlight
|
||
updated.note = self.normalizedNote(note)
|
||
self.highlights[index] = updated
|
||
self.tableView.reloadData()
|
||
self.onUpdateHighlight?(updated)
|
||
self.updateEmptyState()
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||
highlights.remove(at: index)
|
||
tableView.reloadData()
|
||
onDeleteHighlight?(highlight)
|
||
updateEmptyState()
|
||
}
|
||
|
||
private func normalizedNote(_ note: String?) -> String? {
|
||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
}
|
||
|
||
// MARK: - 书签管理面板
|
||
|
||
/// EPUB 阅读器的书签管理控制器
|
||
/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签
|
||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||
/// 用户选择跳转到某个书签位置时的回调
|
||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||
/// 用户删除书签时的回调
|
||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||
|
||
private var bookmarks: [RDEPUBBookmark]
|
||
private let theme: RDEPUBReaderTheme
|
||
private let dateFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||
return formatter
|
||
}()
|
||
|
||
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
|
||
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
|
||
self.theme = theme
|
||
super.init(style: .plain)
|
||
title = "书签"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||
tableView.tableFooterView = UIView(frame: .zero)
|
||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||
applyTheme()
|
||
updateEmptyState()
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
bookmarks.count
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
let cellIdentifier = "BookmarkCell"
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||
let bookmark = bookmarks[indexPath.row]
|
||
|
||
cell.backgroundColor = theme.contentBackgroundColor
|
||
cell.textLabel?.textColor = theme.contentTextColor
|
||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||
cell.textLabel?.numberOfLines = 2
|
||
cell.textLabel?.text = titleText(for: bookmark)
|
||
|
||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||
cell.detailTextLabel?.numberOfLines = 3
|
||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||
cell.accessoryType = .disclosureIndicator
|
||
return cell
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
|
||
}
|
||
|
||
private func applyTheme() {
|
||
tableView.backgroundColor = theme.contentBackgroundColor
|
||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||
}
|
||
|
||
private func updateEmptyState() {
|
||
guard bookmarks.isEmpty == false else {
|
||
let label = UILabel()
|
||
label.text = "暂无书签"
|
||
label.textAlignment = .center
|
||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||
label.numberOfLines = 0
|
||
tableView.backgroundView = label
|
||
return
|
||
}
|
||
tableView.backgroundView = nil
|
||
}
|
||
|
||
private func titleText(for bookmark: RDEPUBBookmark) -> String {
|
||
if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty {
|
||
return chapterTitle
|
||
}
|
||
return bookmark.location.href
|
||
}
|
||
|
||
private func detailText(for bookmark: RDEPUBBookmark) -> String {
|
||
let parts = [
|
||
dateFormatter.string(from: bookmark.createdAt),
|
||
bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
bookmark.location.href
|
||
].compactMap { value -> String? in
|
||
guard let value, !value.isEmpty else { return nil }
|
||
return value
|
||
}
|
||
return parts.joined(separator: "\n")
|
||
}
|
||
|
||
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
|
||
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
|
||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
||
self?.onSelectBookmark?(bookmark)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
|
||
self?.deleteBookmark(bookmark)
|
||
})
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
|
||
if let popover = alert.popoverPresentationController,
|
||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
||
popover.sourceView = cell
|
||
popover.sourceRect = cell.bounds
|
||
}
|
||
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||
bookmarks.remove(at: index)
|
||
tableView.reloadData()
|
||
onDeleteBookmark?(bookmark)
|
||
updateEmptyState()
|
||
}
|
||
}
|