feat: improve epub reader controls and annotations

This commit is contained in:
shen
2026-07-12 12:23:10 +08:00
parent 063a493b18
commit 8ccb7157b2
36 changed files with 2959 additions and 2160 deletions
@@ -0,0 +1,135 @@
import UIKit
final class RDEPUBAnnotationEditorViewController: UIViewController, UITextViewDelegate {
private let quote: String
private let initialNote: String?
private let onSave: (String?) -> Void
private let textView = UITextView()
private let countLabel = UILabel()
init(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
self.quote = quote
self.initialNote = initialNote
self.onSave = onSave
super.init(nibName: nil, bundle: nil)
title = "写注释"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
configureNavigation()
configureContent()
updateCount()
DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() }
}
private func configureNavigation() {
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "取消", style: .plain, target: self, action: #selector(cancelAction)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "保存", style: .done, target: self, action: #selector(saveAction)
)
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
navigationController?.navigationBar.titleTextAttributes = [
.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1),
.font: UIFont.systemFont(ofSize: 17, weight: .semibold)
]
}
private func configureContent() {
let quoteCard = UIView()
let rail = UIView()
let quoteLabel = UILabel()
let inputCard = UIView()
[quoteCard, rail, quoteLabel, inputCard, textView, countLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(quoteCard)
quoteCard.addSubview(rail)
quoteCard.addSubview(quoteLabel)
view.addSubview(inputCard)
inputCard.addSubview(textView)
inputCard.addSubview(countLabel)
quoteCard.backgroundColor = UIColor.white.withAlphaComponent(0.78)
quoteCard.layer.cornerRadius = 16
rail.backgroundColor = UIColor(red: 0.94, green: 0.63, blue: 0.18, alpha: 1)
rail.layer.cornerRadius = 2
quoteLabel.text = quote
quoteLabel.numberOfLines = 5
quoteLabel.font = UIFont.systemFont(ofSize: 16)
quoteLabel.textColor = UIColor(red: 0.19, green: 0.20, blue: 0.23, alpha: 1)
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = 18
inputCard.layer.shadowColor = UIColor.black.cgColor
inputCard.layer.shadowOpacity = 0.06
inputCard.layer.shadowRadius = 18
inputCard.layer.shadowOffset = CGSize(width: 0, height: 6)
textView.text = initialNote
textView.delegate = self
textView.font = UIFont.systemFont(ofSize: 17)
textView.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
textView.backgroundColor = .clear
textView.accessibilityIdentifier = "epub.reader.annotation.editor"
countLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
countLabel.textColor = .secondaryLabel
countLabel.textAlignment = .right
NSLayoutConstraint.activate([
quoteCard.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
quoteCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
quoteCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
rail.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 16),
rail.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
rail.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
rail.widthAnchor.constraint(equalToConstant: 4),
quoteLabel.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 14),
quoteLabel.trailingAnchor.constraint(equalTo: quoteCard.trailingAnchor, constant: -18),
quoteLabel.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
quoteLabel.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
inputCard.topAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: 18),
inputCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
inputCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
inputCard.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16),
inputCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 210),
textView.topAnchor.constraint(equalTo: inputCard.topAnchor, constant: 14),
textView.leadingAnchor.constraint(equalTo: inputCard.leadingAnchor, constant: 14),
textView.trailingAnchor.constraint(equalTo: inputCard.trailingAnchor, constant: -14),
textView.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -8),
countLabel.leadingAnchor.constraint(equalTo: inputCard.leadingAnchor, constant: 16),
countLabel.trailingAnchor.constraint(equalTo: inputCard.trailingAnchor, constant: -16),
countLabel.bottomAnchor.constraint(equalTo: inputCard.bottomAnchor, constant: -12)
])
}
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 1000 {
textView.text = String(textView.text.prefix(1000))
}
updateCount()
}
private func updateCount() {
countLabel.text = "\(textView.text.count)/1000"
}
@objc private func cancelAction() {
dismiss(animated: true)
}
@objc private func saveAction() {
let note = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(note.isEmpty ? nil : note)
dismiss(animated: true)
}
}
@@ -52,7 +52,7 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottom
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "")
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "")
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
chapterButton.accessibilityIdentifier = "epub.reader.toc"
@@ -130,10 +130,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
highlight: RDEPUBHighlight
) {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
runtime.handleHighlightMenuAction(action, highlight: highlight)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
@@ -5,14 +5,16 @@ extension RDEPUBReaderController {
func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection()
let allowsLandscapeDualPage = configuration.landscapeDualPageEnabled
&& publication?.requiresSinglePagePresentation != true
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|| readerView.landscapeDualPageEnabled != allowsLandscapeDualPage
|| readerView.pageDirection != resolvedDirection
let preservedLocation = presentationDidChange
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
: nil
view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
readerView.landscapeDualPageEnabled = allowsLandscapeDualPage
readerView.pageDirection = resolvedDirection
updateReaderChrome()
if presentationDidChange {
@@ -374,14 +374,11 @@ public final class RDEPUBReaderController: UIViewController {
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
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),
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
searchBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor)
])
searchBarView.alpha = 0
@@ -14,14 +14,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
private let filterControl = UISegmentedControl(items: ["全部", "", "高亮"])
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 }
return highlights.filter { !$0.hasNote }
default:
return highlights
}
@@ -48,7 +48,11 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
view.accessibilityIdentifier = "epub.reader.highlights.panel"
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
tableView.separatorStyle = .none
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
navigationItem.rightBarButtonItem = editButtonItem
editButtonItem.title = "编辑"
tableView.allowsMultipleSelectionDuringEditing = false
configureFilterControl()
applyTheme()
updateEmptyState()
@@ -63,23 +67,42 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
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.backgroundColor = .clear
cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
cell.contentView.layer.cornerRadius = 14
cell.contentView.layer.masksToBounds = true
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
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?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
cell.detailTextLabel?.numberOfLines = 3
cell.detailTextLabel?.text = detailText(for: highlight)
cell.accessoryType = .disclosureIndicator
cell.accessoryType = .none
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 112 }
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.contentView.frame = cell.bounds.inset(by: UIEdgeInsets(top: 6, left: 18, bottom: 6, right: 18))
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard !tableView.isEditing else { return }
tableView.deselectRow(at: indexPath, animated: true)
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
onSelectHighlight?(filteredHighlights[indexPath.row])
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
deleteHighlight(filteredHighlights[indexPath.row])
}
private func configureFilterControl() {
@@ -90,10 +113,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
}
private func applyTheme() {
tableView.backgroundColor = theme.contentBackgroundColor
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
}
@@ -120,7 +143,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func emptyStateText() -> String {
switch filterControl.selectedSegmentIndex {
case 1:
return "暂无"
return "暂无注"
case 2:
return "暂无划线"
default:
@@ -142,7 +165,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func titleText(for highlight: RDEPUBHighlight) -> String {
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
if highlight.hasNote {
return "批注: \(text)"
return "注释:\(text)"
}
return text
}
@@ -150,58 +173,12 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
switch highlight.style {
case .highlight:
return highlight.hasNote ? "高亮批注" : "高亮"
return highlight.hasNote ? "划线注释" : "划线"
case .underline:
return highlight.hasNote ? "划线" : "划线"
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)
@@ -248,7 +225,10 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
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)
tableView.separatorStyle = .none
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
navigationItem.rightBarButtonItem = editButtonItem
editButtonItem.title = "编辑"
applyTheme()
updateEmptyState()
}
@@ -262,30 +242,40 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
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.backgroundColor = .clear
cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
cell.contentView.layer.cornerRadius = 14
cell.contentView.layer.masksToBounds = true
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
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?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
cell.detailTextLabel?.numberOfLines = 3
cell.detailTextLabel?.text = detailText(for: bookmark)
cell.accessoryType = .disclosureIndicator
cell.accessoryType = .none
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 104 }
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
deleteBookmark(bookmarks[indexPath.row])
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
onSelectBookmark?(bookmarks[indexPath.row])
}
private func applyTheme() {
tableView.backgroundColor = theme.contentBackgroundColor
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
}
@@ -322,25 +312,6 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
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)
@@ -119,21 +119,13 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
super.apply(theme: theme)
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
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)
let overlayColor = UIColor(white: 0.05, alpha: 0.32)
let panelColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
let rowColor = UIColor.white.withAlphaComponent(0.88)
let cardColor = UIColor.white.withAlphaComponent(0.78)
let activeCardColor = UIColor(red: 0.90, green: 0.94, blue: 1.0, alpha: 1)
let textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
let secondaryTextColor = UIColor(red: 0.38, green: 0.40, blue: 0.44, alpha: 1)
backgroundColor = .clear
backgroundButton.backgroundColor = overlayColor
@@ -141,7 +133,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
searchRowView.backgroundColor = rowColor
searchFieldContainer.backgroundColor = .clear
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
searchFieldDivider.backgroundColor = UIColor.black.withAlphaComponent(0.10)
searchIcon.tintColor = secondaryTextColor
cancelButton.tintColor = textColor
cancelButton.setTitleColor(textColor, for: .normal)
@@ -165,8 +157,8 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
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)
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
tableView.reloadData()
}
@@ -274,10 +266,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchIcon.contentMode = .scaleAspectFit
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
panelView.layer.cornerRadius = 28
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.layer.cornerRadius = 0
panelView.clipsToBounds = true
grabberView.isHidden = true
grabberView.layer.cornerRadius = 3
searchRowView.layer.cornerRadius = 22
searchFieldContainer.layer.cornerRadius = 22
@@ -317,7 +309,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
panelView.topAnchor.constraint(equalTo: topAnchor),
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
@@ -327,7 +319,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
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.topAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.topAnchor, constant: 12),
searchRowView.heightAnchor.constraint(equalToConstant: 52),
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
@@ -480,7 +472,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
label.textColor = UIColor(white: 0.96, alpha: 1)
label.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
label.text = searchSections[section].title
label.numberOfLines = 2
container.addSubview(label)
@@ -339,6 +339,7 @@ public final class RDEpubURLReaderController: UIViewController {
let state = [
"reader=opened",
"page=\(page)",
"pagesPerScreen=\(readerController?.readerView.pagesPerScreen ?? 1)",
"display=\(display)",
"toolbar=\(toolbar)",
"highlights=\(highlights)",
@@ -53,7 +53,7 @@ final class RDEPUBReaderAnnotationCoordinator {
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
addAnnotation(from: selection, style: .underline, color: color, note: note)
}
@discardableResult
@@ -61,7 +61,8 @@ final class RDEPUBReaderAnnotationCoordinator {
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
note: String? = nil,
isAnnotationOnly: Bool = false
) -> RDEPUBHighlight? {
guard let controller else { return nil }
let sourceSelection = selection ?? controller.currentSelection
@@ -71,22 +72,35 @@ final class RDEPUBReaderAnnotationCoordinator {
return nil
}
let newHighlight = RDEPUBHighlight(
var newHighlight = RDEPUBHighlight(
bookIdentifier: controller.currentBookIdentifier,
location: scopedSelection.location,
text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo,
style: style,
style: .underline,
color: color,
note: note
note: note,
isAnnotationOnly: isAnnotationOnly
)
let mergeCandidates = controller.activeHighlights.filter {
shouldMerge($0, with: newHighlight)
}
if !mergeCandidates.isEmpty {
newHighlight = mergedHighlight(newHighlight, with: mergeCandidates)
controller.activeHighlights.removeAll { candidate in
mergeCandidates.contains { $0.id == candidate.id }
}
}
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
highlight.style == newHighlight.style &&
highlight.isAnnotationOnly == newHighlight.isAnnotationOnly
}
guard !isDuplicate else {
return nil
@@ -219,25 +233,29 @@ final class RDEPUBReaderAnnotationCoordinator {
presentAnnotationActionSheet(for: currentSelection)
}
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
guard let controller else { return }
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
_ = self?.removeHighlight(id: highlight.id)
})
if highlight.hasNote {
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
})
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
switch action {
case .copy:
UIPasteboard.general.string = highlight.text
case .createUnderline:
guard highlight.isAnnotationOnly,
let controller,
let index = controller.activeHighlights.firstIndex(where: { $0.id == highlight.id }) else {
return
}
controller.activeHighlights[index].isAnnotationOnly = false
persistHighlightsAndRefreshContent()
case .deleteUnderline:
_ = removeHighlight(id: highlight.id)
case .annotate:
presentAnnotationNoteEditor(for: highlight)
case .deleteAnnotation:
if highlight.isAnnotationOnly {
_ = removeHighlight(id: highlight.id)
} else {
_ = updateHighlightNote(id: highlight.id, note: nil)
}
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = sourceView
popover.sourceRect = sourceRect
}
controller.present(alert, animated: true)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
@@ -247,7 +265,7 @@ final class RDEPUBReaderAnnotationCoordinator {
UIPasteboard.general.string = selection.text
updateCurrentSelection(nil)
case .highlight:
createAnnotation(from: selection, style: .highlight)
createAnnotation(from: selection, style: .underline)
case .annotate:
presentAnnotationNoteEditor(for: selection)
}
@@ -365,13 +383,65 @@ final class RDEPUBReaderAnnotationCoordinator {
location: normalizedLocation,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
style: highlight.style,
style: .underline,
color: highlight.color,
note: highlight.note,
isAnnotationOnly: highlight.isAnnotationOnly,
createdAt: highlight.createdAt
)
}
private func shouldMerge(_ existing: RDEPUBHighlight, with incoming: RDEPUBHighlight) -> Bool {
guard existing.location.href == incoming.location.href,
let existingRange = RDEPUBTextOffsetRangeInfo.decode(from: existing.rangeInfo)?.nsRange,
let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo)?.nsRange else {
return false
}
// Only merge identical selections. Contained or partially-overlapping
// ranges remain independent so a reader can still manage each mark.
return existingRange.location == incomingRange.location
&& existingRange.length == incomingRange.length
}
private func mergedHighlight(
_ incoming: RDEPUBHighlight,
with existingHighlights: [RDEPUBHighlight]
) -> RDEPUBHighlight {
var result = incoming
let allHighlights = existingHighlights + [incoming]
let ranges = allHighlights.compactMap {
RDEPUBTextOffsetRangeInfo.decode(from: $0.rangeInfo)
}
if let first = ranges.first {
let start = ranges.map(\.start).min() ?? first.start
let end = ranges.map(\.end).max() ?? first.end
result.rangeInfo = RDEPUBTextOffsetRangeInfo(href: first.href, start: start, end: end).jsonString()
}
// The latest selection is exact whenever it encloses the merged range;
// otherwise keep every distinct excerpt rather than silently losing
// text that may later be copied from the marking list.
if let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo),
let mergedRange = RDEPUBTextOffsetRangeInfo.decode(from: result.rangeInfo),
incomingRange.start <= mergedRange.start,
incomingRange.end >= mergedRange.end {
result.text = incoming.text
} else {
result.text = allHighlights.map(\.text).reduce(into: [String]()) { texts, text in
if !texts.contains(text) { texts.append(text) }
}.joined(separator: " ")
}
let notes = allHighlights.compactMap(\.note).map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
let uniqueNotes = notes.reduce(into: [String]()) { values, note in
if !values.contains(note) { values.append(note) }
}
result.note = uniqueNotes.isEmpty ? nil : uniqueNotes.joined(separator: "\n\n")
return result
}
@discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false }
@@ -410,13 +480,10 @@ final class RDEPUBReaderAnnotationCoordinator {
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
alert.addAction(UIAlertAction(title: "", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@@ -429,25 +496,48 @@ final class RDEPUBReaderAnnotationCoordinator {
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 createAnnotation(
from selection: RDEPUBSelection,
style: RDEPUBHighlightStyle,
note: String? = nil,
isAnnotationOnly: Bool = false
) {
_ = addAnnotation(
from: selection,
style: style,
note: note,
isAnnotationOnly: isAnnotationOnly
)
}
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
presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
guard note != nil else { return }
self?.createAnnotation(
from: selection,
style: .highlight,
note: alert?.textFields?.first?.text
style: .underline,
note: note,
isAnnotationOnly: true
)
})
controller.present(alert, animated: true)
}
}
private func presentAnnotationNoteEditor(for highlight: RDEPUBHighlight) {
presentNoteEditor(quote: highlight.text, initialNote: highlight.note) { [weak self] note in
_ = self?.updateHighlightNote(id: highlight.id, note: note)
}
}
private func presentNoteEditor(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
guard let controller else { return }
let editor = RDEPUBAnnotationEditorViewController(
quote: quote,
initialNote: initialNote,
onSave: onSave
)
let navigationController = UINavigationController(rootViewController: editor)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
@@ -4,6 +4,8 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
private unowned let context: RDEPUBReaderContext
private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate?
init(context: RDEPUBReaderContext) {
self.context = context
}
@@ -122,9 +124,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
}
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak controller] displayType in
controller?.updateConfiguration { $0.displayType = displayType }
}
@@ -137,8 +136,13 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
}
let navigationController = UINavigationController(rootViewController: settingsController)
navigationController.modalPresentationStyle = .pageSheet
navigationController.presentationController?.delegate = self
let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate()
settingsTransitionDelegate = transitionDelegate
navigationController.setNavigationBarHidden(true, animated: false)
navigationController.view.backgroundColor = .clear
navigationController.view.isOpaque = false
navigationController.modalPresentationStyle = .custom
navigationController.transitioningDelegate = transitionDelegate
controller.present(navigationController, animated: true)
}
@@ -233,3 +237,77 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return candidate
}
}
private final class RDEPUBReaderSettingsTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
RDEPUBReaderSettingsPresentationController(
presentedViewController: presented,
presenting: presenting ?? source
)
}
}
private final class RDEPUBReaderSettingsPresentationController: UIPresentationController {
private let dimmingView = UIControl()
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView else { return .zero }
let bounds = containerView.bounds
let isLandscape = bounds.width > bounds.height
if isLandscape {
let width = min(390, max(300, bounds.width * 0.32))
let height = min(340, bounds.height - 32)
return CGRect(
x: bounds.maxX - width - 16,
y: bounds.midY - height / 2,
width: width,
height: height
)
}
let height = min(350, max(280, bounds.height * 0.40))
return CGRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
}
override func presentationTransitionWillBegin() {
guard let containerView else { return }
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.18)
dimmingView.alpha = 0
dimmingView.addTarget(self, action: #selector(dismissPresentedController), for: .touchUpInside)
dimmingView.frame = containerView.bounds
containerView.insertSubview(dimmingView, at: 0)
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1
})
}
override func dismissalTransitionWillBegin() {
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0
})
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
dimmingView.frame = containerView?.bounds ?? .zero
presentedView?.frame = frameOfPresentedViewInContainerView
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
if completed { dimmingView.removeFromSuperview() }
}
@objc private func dismissPresentedController() {
if let navigationController = presentedViewController as? UINavigationController,
let settingsController = navigationController.topViewController as? RDEPUBReaderSettingsViewController {
settingsController.notifyDismissalIfNeeded()
}
presentedViewController.dismiss(animated: true)
}
}
@@ -163,7 +163,11 @@ final class RDEPUBReaderContext {
}
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
var preferences = environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
if publication?.requiresSinglePagePresentation == true {
preferences.fixedLayoutSpreadMode = .never
}
return preferences
}
func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets {
@@ -354,10 +354,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
@@ -256,8 +256,8 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.presentAnnotationCreation()
}
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
annotationCoordinator.handleHighlightMenuAction(action, highlight: highlight)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
@@ -1,30 +1,19 @@
import UIKit
/// Compact reader settings drawer. It deliberately keeps the book visible and
/// exposes only the controls currently supported by the reader configuration.
final class RDEPUBReaderSettingsViewController: UIViewController {
var onBrightnessChange: ((CGFloat) -> Void)?
var onFontSizeChange: ((CGFloat) -> Void)?
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
var onLineHeightChange: ((CGFloat) -> Void)?
var onColumnCountChange: ((Int) -> Void)?
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
var onDismiss: (() -> Void)?
private enum ThemePreset: Int, CaseIterable {
case light
case yellow
case green
case pink
case blue
case dark
case light, yellow, green, pink, blue, dark
var title: String {
switch self {
@@ -49,50 +38,24 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
}
}
private let scrollView = UIScrollView()
private let contentStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 20
return stackView
}()
private let handleView = UIView()
private let closeButton = UIButton(type: .system)
private let brightnessSlider = UISlider()
private let fontValueLabel = UILabel()
private let decreaseFontButton = UIButton(type: .system)
private let increaseFontButton = UIButton(type: .system)
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
private let themeStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.spacing = 12
return stackView
}()
private let fontSizeSlider = UISlider()
private let fontSizeLabel = UILabel()
private let fontChoiceButton = UIButton(type: .system)
private let lineHeightButton = UIButton(type: .system)
private let displayTypeButton = UIButton(type: .system)
private let themeStack = UIStackView()
private var themeButtons: [UIButton] = []
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
private var currentConfiguration: RDEPUBReaderConfiguration
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
private var hasNotifiedDismissal = false
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
self.currentConfiguration = configuration
currentConfiguration = configuration
super.init(nibName: nil, bundle: nil)
brightnessSlider.value = Float(brightness)
title = "样式设置"
}
required init?(coder: NSCoder) {
@@ -101,262 +64,266 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationItems()
setupViews()
syncControls()
applyTheme(currentConfiguration.theme)
}
private func setupNavigationItems() {
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
navigationItem.rightBarButtonItem = doneItem
}
private func setupViews() {
view.addSubview(scrollView)
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentStack)
contentStack.translatesAutoresizingMaskIntoConstraints = false
view.accessibilityIdentifier = "epub.reader.settings.panel"
view.layer.cornerRadius = 24
view.layer.cornerCurve = .continuous
view.clipsToBounds = true
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
[handleView, closeButton, brightnessSlider, fontSizeSlider, fontSizeLabel,
fontChoiceButton, lineHeightButton, displayTypeButton, themeStack].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
])
handleView.layer.cornerRadius = 3
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
closeButton.accessibilityIdentifier = "epub.reader.settings.done"
closeButton.accessibilityLabel = "完成"
closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
brightnessSlider.minimumValue = 0
brightnessSlider.maximumValue = 1
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
fontValueLabel.textAlignment = .center
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
fontSizeSlider.minimumValue = 12
fontSizeSlider.maximumValue = 36
fontSizeSlider.accessibilityIdentifier = "epub.reader.settings.font.size"
fontSizeSlider.addTarget(self, action: #selector(fontSizeChanged(_:)), for: .valueChanged)
fontSizeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .semibold)
fontSizeLabel.textAlignment = .center
fontSizeLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
configureFontButton(decreaseFontButton, title: "A-")
configureFontButton(increaseFontButton, title: "A+")
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
configurePill(fontChoiceButton, title: "字体")
configurePill(lineHeightButton, title: "行距")
configurePill(displayTypeButton, title: "翻页方式")
fontChoiceButton.accessibilityIdentifier = "epub.reader.settings.font.choice"
lineHeightButton.accessibilityIdentifier = "epub.reader.settings.lineHeight"
displayTypeButton.accessibilityIdentifier = "epub.reader.settings.displayType"
fontChoiceButton.addTarget(self, action: #selector(fontChoiceAction), for: .touchUpInside)
lineHeightButton.addTarget(self, action: #selector(lineHeightAction), for: .touchUpInside)
displayTypeButton.addTarget(self, action: #selector(displayTypeAction), for: .touchUpInside)
themeStack.axis = .horizontal
themeStack.distribution = .fillEqually
themeStack.spacing = 14
ThemePreset.allCases.forEach { preset in
let button = UIButton(type: .system)
button.tag = preset.rawValue
button.layer.cornerRadius = 18
button.layer.borderWidth = 1.5
button.backgroundColor = preset.theme.contentBackgroundColor
button.layer.cornerRadius = 15
button.layer.borderWidth = 1
button.accessibilityLabel = preset.title
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
themeButtons.append(button)
themeStackView.addArrangedSubview(button)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 36)
])
themeStack.addArrangedSubview(button)
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
}
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
contentStack.addArrangedSubview(makeSection(title: "", content: makeFontSizeRow()))
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
}
let brightnessLabel = makeLabel("亮度")
let fontSizeTitle = makeLabel("体大小")
let smallA = makeLabel("A", font: 18)
let largeA = makeLabel("A", font: 25)
[brightnessLabel, fontSizeTitle, smallA, largeA].forEach(view.addSubview)
private func makeSection(title: String, content: UIView) -> UIView {
let container = UIStackView()
container.axis = .vertical
container.spacing = 10
let selectorStack = UIStackView(arrangedSubviews: [fontChoiceButton, lineHeightButton, displayTypeButton])
selectorStack.axis = .horizontal
selectorStack.distribution = .fillEqually
selectorStack.spacing = 10
selectorStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(selectorStack)
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
content.translatesAutoresizingMaskIntoConstraints = false
container.addArrangedSubview(titleLabel)
container.addArrangedSubview(content)
return container
}
private func makeFontSizeRow() -> UIView {
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .fill
stackView.spacing = 12
let dividerOne = makeDivider()
let dividerTwo = makeDivider()
[dividerOne, dividerTwo].forEach(view.addSubview)
NSLayoutConstraint.activate([
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
handleView.widthAnchor.constraint(equalToConstant: 42),
handleView.heightAnchor.constraint(equalToConstant: 5),
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32),
brightnessLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
brightnessLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
brightnessSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
brightnessSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
brightnessSlider.topAnchor.constraint(equalTo: brightnessLabel.bottomAnchor, constant: 5),
dividerOne.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
dividerOne.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
dividerOne.topAnchor.constraint(equalTo: brightnessSlider.bottomAnchor, constant: 10),
fontSizeTitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
fontSizeTitle.topAnchor.constraint(equalTo: dividerOne.bottomAnchor, constant: 10),
smallA.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
smallA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
fontSizeSlider.leadingAnchor.constraint(equalTo: smallA.trailingAnchor, constant: 10),
fontSizeSlider.trailingAnchor.constraint(equalTo: largeA.leadingAnchor, constant: -10),
fontSizeSlider.topAnchor.constraint(equalTo: fontSizeTitle.bottomAnchor, constant: 4),
largeA.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
largeA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
fontSizeLabel.centerXAnchor.constraint(equalTo: fontSizeSlider.centerXAnchor),
fontSizeLabel.bottomAnchor.constraint(equalTo: fontSizeSlider.topAnchor, constant: -1),
dividerTwo.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
dividerTwo.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
dividerTwo.topAnchor.constraint(equalTo: fontSizeSlider.bottomAnchor, constant: 10),
selectorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
selectorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
selectorStack.topAnchor.constraint(equalTo: dividerTwo.bottomAnchor, constant: 12),
selectorStack.heightAnchor.constraint(equalToConstant: 42),
themeStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
themeStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
themeStack.topAnchor.constraint(equalTo: selectorStack.bottomAnchor, constant: 16),
themeStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -14)
])
return stackView
}
private func configureFontButton(_ button: UIButton, title: String) {
private func makeLabel(_ text: String, font: CGFloat = 13) -> UILabel {
let label = UILabel()
label.text = text
label.font = UIFont.systemFont(ofSize: font, weight: font > 16 ? .regular : .semibold)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}
private func makeDivider() -> UIView {
let divider = UIView()
divider.translatesAutoresizingMaskIntoConstraints = false
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
return divider
}
private func configurePill(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
button.layer.cornerRadius = 18
button.layer.borderWidth = 1
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
button.layer.cornerRadius = 13
}
private func syncControls() {
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
lineHeightControl.selectedSegmentIndex = lineHeightIndex
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
fontSizeSlider.value = Float(currentConfiguration.fontSize)
fontSizeLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
updateSelectorTitles()
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light)
}
private func updateSelectorTitles() {
fontChoiceButton.setTitle("\(currentConfiguration.fontChoice.displayName) ", for: .normal)
let lineIndex = lineHeightValues.enumerated().min {
abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple)
}?.offset ?? 1
fontChoiceButton.accessibilityValue = currentConfiguration.fontChoice.displayName
let lineTitles = ["紧凑", "标准", "宽松"]
lineHeightButton.setTitle("\(lineTitles[lineIndex]) ", for: .normal)
lineHeightButton.accessibilityValue = lineTitles[lineIndex]
let displayTitle: String
switch currentConfiguration.displayType {
case .pageCurl:
displayTypeControl.selectedSegmentIndex = 0
case .horizontalScroll:
displayTypeControl.selectedSegmentIndex = 1
case .verticalScroll:
displayTypeControl.selectedSegmentIndex = 2
case .pageCurl: displayTitle = "仿真"
case .horizontalScroll: displayTitle = "横滑"
case .verticalScroll: displayTitle = "竖滑"
}
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
updateThemeSelection(selectedPreset)
updateControlAccessibilityValues()
displayTypeButton.setTitle("\(displayTitle) ", for: .normal)
displayTypeButton.accessibilityValue = displayTitle
}
private func applyTheme(_ theme: RDEPUBReaderTheme) {
view.backgroundColor = theme.contentBackgroundColor
scrollView.backgroundColor = theme.contentBackgroundColor
contentStack.arrangedSubviews
.compactMap { $0 as? UIStackView }
.flatMap { $0.arrangedSubviews }
.compactMap { $0 as? UILabel }
.forEach { $0.textColor = theme.contentTextColor }
fontValueLabel.textColor = theme.contentTextColor
[decreaseFontButton, increaseFontButton].forEach { button in
button.setTitleColor(theme.toolControlTextColor, for: .normal)
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
button.backgroundColor = theme.toolBackgroundColor
view.backgroundColor = theme.toolBackgroundColor.withAlphaComponent(0.98)
let textColor = theme.toolControlTextColor
handleView.backgroundColor = textColor.withAlphaComponent(0.45)
closeButton.tintColor = textColor
view.subviews.compactMap { $0 as? UILabel }.forEach { $0.textColor = textColor }
view.subviews.filter { $0 !== handleView && $0 !== closeButton && !($0 is UISlider) && !($0 is UIStackView) && !($0 is UILabel) }
.forEach { $0.backgroundColor = textColor.withAlphaComponent(0.12) }
[fontChoiceButton, lineHeightButton, displayTypeButton].forEach {
$0.setTitleColor(textColor, for: .normal)
$0.backgroundColor = textColor.withAlphaComponent(0.10)
}
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
control.backgroundColor = theme.toolBackgroundColor
if #available(iOS 13.0, *) {
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
} else {
control.tintColor = theme.toolControlTextColor
}
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
}
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
brightnessSlider.minimumTrackTintColor = .systemBlue
fontSizeSlider.minimumTrackTintColor = .systemBlue
brightnessSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
fontSizeSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
}
private func updateThemeSelection(_ preset: ThemePreset) {
themeButtons.forEach { button in
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"
let selected = button.tag == preset.rawValue
button.layer.borderWidth = selected ? 2.5 : 1
button.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.withAlphaComponent(0.28).cgColor
button.accessibilityValue = selected ? "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)
func notifyDismissalIfNeeded() {
guard !hasNotifiedDismissal else { return }
hasNotifiedDismissal = true
onDismiss?()
}
@objc private func doneAction() {
dismiss(animated: true) { [weak self] in
self?.onDismiss?()
notifyDismissalIfNeeded()
dismiss(animated: true)
}
@objc private func brightnessChanged(_ sender: UISlider) { onBrightnessChange?(CGFloat(sender.value)) }
@objc private func fontSizeChanged(_ sender: UISlider) {
let value = CGFloat(sender.value.rounded())
guard value != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = value
fontSizeLabel.text = String(Int(value))
onFontSizeChange?(value)
}
@objc private func fontChoiceAction() {
let alert = UIAlertController(title: "字体", message: nil, preferredStyle: .actionSheet)
RDEPUBReaderFontChoice.allCases.forEach { choice in
alert.addAction(UIAlertAction(title: choice.displayName, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.fontChoice = choice
self.updateSelectorTitles()
self.onFontChoiceChange?(choice)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func brightnessChanged(_ slider: UISlider) {
onBrightnessChange?(CGFloat(slider.value))
}
@objc private func decreaseFontAction() {
let nextValue = max(12, currentConfiguration.fontSize - 1)
guard nextValue != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = nextValue
fontValueLabel.text = String(Int(nextValue.rounded()))
onFontSizeChange?(nextValue)
}
@objc private func increaseFontAction() {
let nextValue = min(36, currentConfiguration.fontSize + 1)
guard nextValue != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = nextValue
fontValueLabel.text = String(Int(nextValue.rounded()))
onFontSizeChange?(nextValue)
}
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
let choices = RDEPUBReaderFontChoice.allCases
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
let choice = choices[index]
guard choice != currentConfiguration.fontChoice else { return }
currentConfiguration.fontChoice = choice
updateControlAccessibilityValues()
onFontChoiceChange?(choice)
}
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
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)
}
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
let displayType: RDEpubReaderView.DisplayType
switch control.selectedSegmentIndex {
case 1:
displayType = .horizontalScroll
case 2:
displayType = .verticalScroll
default:
displayType = .pageCurl
@objc private func lineHeightAction() {
let alert = UIAlertController(title: "行距", message: nil, preferredStyle: .actionSheet)
zip(["紧凑", "标准", "宽松"], lineHeightValues).forEach { title, value in
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.lineHeightMultiple = value
self.updateSelectorTitles()
self.onLineHeightChange?(value)
})
}
currentConfiguration.displayType = displayType
updateControlAccessibilityValues()
onDisplayTypeChange?(displayType)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func displayTypeAction() {
let alert = UIAlertController(title: "翻页方式", message: nil, preferredStyle: .actionSheet)
[("仿真", RDEpubReaderView.DisplayType.pageCurl), ("横滑", .horizontalScroll), ("竖滑", .verticalScroll)].forEach { title, type in
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.displayType = type
self.updateSelectorTitles()
self.onDisplayTypeChange?(type)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func themeButtonAction(_ sender: UIButton) {
@@ -28,11 +28,19 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
highlight: RDEPUBHighlight
)
}
enum RDEPUBExistingHighlightMenuAction {
case copy
case createUnderline
case deleteUnderline
case annotate
case deleteAnnotation
}
extension RDEPUBTextContentViewDelegate {
func textContentView(
_ contentView: RDEPUBTextContentView,
@@ -60,6 +68,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
private var menuSelection: RDEPUBSelection?
/// The underline whose compact text menu is currently visible. Keeping this
/// separate from `currentSelection` means a marked paragraph is still fully
/// selectable with a long press.
private var menuHighlight: RDEPUBHighlight?
private var menuUnderlineHighlight: RDEPUBHighlight?
private var menuAnnotationHighlight: RDEPUBHighlight?
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
// Stored as Any? to avoid @available on stored property restriction.
private var _editMenuInteraction: Any?
@@ -347,23 +364,52 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
action == #selector(rd_copy(_:))
if menuHighlight != nil {
return action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:))
|| action == #selector(rd_deleteUnderline(_:))
|| action == #selector(rd_annotate(_:))
|| action == #selector(rd_deleteAnnotation(_:))
}
return action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:))
|| action == #selector(rd_annotate(_:))
}
@objc func rd_copy(_ sender: Any?) {
if let highlight = menuHighlight {
performHighlightMenuAction(.copy, highlight: highlight)
return
}
performSelectionAction(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
if let highlight = menuAnnotationHighlight {
performHighlightMenuAction(.createUnderline, highlight: highlight)
return
}
performSelectionAction(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
if let highlight = menuHighlight {
performHighlightMenuAction(.annotate, highlight: highlight)
return
}
performSelectionAction(.annotate)
}
@objc func rd_deleteUnderline(_ sender: Any?) {
guard let highlight = menuUnderlineHighlight else { return }
performHighlightMenuAction(.deleteUnderline, highlight: highlight)
}
@objc func rd_deleteAnnotation(_ sender: Any?) {
guard let highlight = menuAnnotationHighlight else { return }
performHighlightMenuAction(.deleteAnnotation, highlight: highlight)
}
override func layoutSubviews() {
super.layoutSubviews()
@@ -855,50 +901,84 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
)
return
}
guard let highlight = highlight(at: point),
let highlights = highlights(at: point)
guard let highlight = highlights.first,
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
return
}
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))")
showHighlightMenu(for: highlights, sourceRect: sourceRect)
}
private func showHighlightMenu(for highlights: [RDEPUBHighlight], sourceRect: CGRect) {
// A short tap opens the compact system menu. Long-press selection is
// intentionally handled by the separate recognizer, which the tap
// recognizer already waits on, so text inside a marking remains
// selectable.
let underline = highlights.first { !$0.isAnnotationOnly }
let annotation = highlights.first { $0.isAnnotationOnly }
?? highlights.first { $0.hasNote }
menuHighlight = annotation ?? underline
menuUnderlineHighlight = underline
menuAnnotationHighlight = annotation
guard menuHighlight != nil else { return }
becomeFirstResponder()
let menuController = UIMenuController.shared
var items = [UIMenuItem(title: "复制", action: #selector(rd_copy(_:)))]
if underline != nil {
items.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteUnderline(_:))))
} else if annotation != nil {
items.append(UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))))
}
if let annotation, annotation.hasNote {
items.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:))))
} else if let underline, !underline.hasNote {
items.append(UIMenuItem(title: "注释", action: #selector(rd_annotate(_:))))
}
menuController.menuItems = items
menuController.setTargetRect(sourceRect, in: self)
menuController.setMenuVisible(true, animated: true)
}
private func performHighlightMenuAction(
_ action: RDEPUBExistingHighlightMenuAction,
highlight: RDEPUBHighlight
) {
menuHighlight = nil
menuUnderlineHighlight = nil
menuAnnotationHighlight = nil
UIMenuController.shared.setMenuVisible(false, animated: true)
delegate?.textContentView(self, didRequestHighlightMenuAction: action, highlight: highlight)
}
private func showSelectionMenuIfNeeded() {
menuHighlight = nil
menuUnderlineHighlight = nil
menuAnnotationHighlight = nil
guard currentSelection != nil,
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty else {
return
}
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
becomeFirstResponder()
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
DispatchQueue.main.async { [weak self, weak interaction] in
guard let self, self.currentSelection != nil else { return }
interaction?.presentEditMenu(with: config)
}
} else {
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(resolvedTargetRect, in: self)
menuController.setMenuVisible(true, animated: true)
}
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(resolvedTargetRect, in: self)
menuController.setMenuVisible(true, animated: true)
}
private func hideSelectionMenu() {
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
interaction.dismissMenu()
} else {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
menuHighlight = nil
menuUnderlineHighlight = nil
menuAnnotationHighlight = nil
UIMenuController.shared.setMenuVisible(false, animated: true)
}
private func updateSelectionLoupe(for point: CGPoint) {
@@ -937,7 +1017,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
highlights(at: point).first
}
private func highlights(at point: CGPoint) -> [RDEPUBHighlight] {
guard let page = currentPage else { return [] }
let matches = currentHighlights.filter { highlight in
guard highlight.location.href == page.href,
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
@@ -952,11 +1036,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
return matches.sorted { lhs, rhs in
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
if lhs.hasNote != rhs.hasNote {
return lhs.hasNote && !rhs.hasNote
if lhsRange != rhsRange {
// In an overlap, the narrower marking is the one the reader
// most likely meant to touch (e.g. underline A inside note B).
return lhsRange < rhsRange
}
return lhsRange < rhsRange
}.first
return lhs.createdAt > rhs.createdAt
}
}
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
@@ -1133,9 +1219,9 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
suggestedActions: [UIMenuElement]
) -> UIMenu? {
UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
UICommand(title: "", action: #selector(rd_annotate(_:)))
UICommand(title: "复制", action: #selector(rd_copy(_:))),
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
UICommand(title: "", action: #selector(rd_annotate(_:)))
])
}