feat: improve epub reader controls and annotations
This commit is contained in:
@@ -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)",
|
||||
|
||||
+133
-43
@@ -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? {
|
||||
|
||||
+83
-5
@@ -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 {
|
||||
|
||||
-4
@@ -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?) {
|
||||
|
||||
+215
-248
@@ -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(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user