unify PDF and EPUB reader: convert highlights to underlines, improve annotation UI, change paging from horizontal to vertical
- Convert PDF highlights from filled rectangles to underlines matching EPUB style - Update PDF annotation editor with EPUB-style design: gold sidebar rail, semi-transparent background, improved spacing and shadows, dark mode support - Change PDF page-curl paging from left-right horizontal to up-down vertical: update gesture recognition and tap regions accordingly Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
72c1f8d89c
commit
fed34da246
@@ -0,0 +1,239 @@
|
||||
import UIKit
|
||||
import RDPDFReaderView
|
||||
|
||||
/// 图片型 PDF 的注释输入页。文字和区域标注共用同一编辑体验。
|
||||
final class PDFDemoAnnotationEditorViewController: 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? = nil, 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()
|
||||
let isDarkMode = traitCollection.userInterfaceStyle == .dark
|
||||
view.backgroundColor = isDarkMode ? UIColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1) : .systemBackground
|
||||
view.accessibilityIdentifier = "pdf.reader.annotation.editor"
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelAction))
|
||||
navigationItem.leftBarButtonItem?.accessibilityIdentifier = "pdf.reader.annotation.cancel"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "保存", style: .done, target: self, action: #selector(saveAction))
|
||||
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "pdf.reader.annotation.save"
|
||||
navigationController?.navigationBar.tintColor = .systemBlue
|
||||
|
||||
let quoteCard = UIView()
|
||||
let rail = UIView()
|
||||
let quoteLabel = UILabel()
|
||||
let textCard = UIView()
|
||||
[quoteCard, rail, quoteLabel, textCard, textView, countLabel].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
view.addSubview(quoteCard)
|
||||
quoteCard.addSubview(rail)
|
||||
quoteCard.addSubview(quoteLabel)
|
||||
view.addSubview(textCard)
|
||||
textCard.addSubview(textView)
|
||||
textCard.addSubview(countLabel)
|
||||
|
||||
let contentBgColor = isDarkMode ? UIColor(red: 0.13, green: 0.13, blue: 0.14, alpha: 1) : UIColor(red: 0.97, green: 0.97, blue: 0.99, alpha: 1)
|
||||
let textColor = isDarkMode ? UIColor(red: 0.92, green: 0.92, blue: 0.96, alpha: 1) : .label
|
||||
|
||||
quoteCard.backgroundColor = contentBgColor.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 = textColor.withAlphaComponent(0.85)
|
||||
|
||||
textCard.backgroundColor = contentBgColor
|
||||
textCard.layer.cornerRadius = 18
|
||||
textCard.layer.shadowColor = UIColor.black.cgColor
|
||||
textCard.layer.shadowOpacity = 0.06
|
||||
textCard.layer.shadowRadius = 18
|
||||
textCard.layer.shadowOffset = CGSize(width: 0, height: 6)
|
||||
textView.text = initialNote
|
||||
textView.delegate = self
|
||||
textView.font = UIFont.systemFont(ofSize: 17)
|
||||
textView.textColor = textColor
|
||||
textView.backgroundColor = .clear
|
||||
textView.accessibilityIdentifier = "pdf.reader.annotation.input"
|
||||
countLabel.font = .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: quoteCard.leadingAnchor, constant: 28),
|
||||
quoteLabel.trailingAnchor.constraint(equalTo: quoteCard.trailingAnchor, constant: -16),
|
||||
quoteLabel.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
|
||||
quoteLabel.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
|
||||
|
||||
textCard.topAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: 20),
|
||||
textCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
textCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||
textCard.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -20),
|
||||
textCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 240),
|
||||
textView.topAnchor.constraint(equalTo: textCard.topAnchor, constant: 16),
|
||||
textView.leadingAnchor.constraint(equalTo: textCard.leadingAnchor, constant: 16),
|
||||
textView.trailingAnchor.constraint(equalTo: textCard.trailingAnchor, constant: -16),
|
||||
textView.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -12),
|
||||
countLabel.leadingAnchor.constraint(equalTo: textCard.leadingAnchor, constant: 16),
|
||||
countLabel.trailingAnchor.constraint(equalTo: textCard.trailingAnchor, constant: -16),
|
||||
countLabel.bottomAnchor.constraint(equalTo: textCard.bottomAnchor, constant: -12)
|
||||
])
|
||||
updateCount()
|
||||
DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() }
|
||||
}
|
||||
|
||||
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() { closeEditor() }
|
||||
|
||||
@objc private func saveAction() {
|
||||
let note = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onSave(note.isEmpty ? nil : note)
|
||||
closeEditor()
|
||||
}
|
||||
|
||||
private func closeEditor() {
|
||||
guard let navigationController else {
|
||||
dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
if navigationController.viewControllers.first === self {
|
||||
navigationController.dismiss(animated: true)
|
||||
} else {
|
||||
navigationController.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一展示文字高亮、区域高亮和带注释的标注,点击可回到原页。
|
||||
final class PDFDemoAnnotationListViewController: UITableViewController {
|
||||
var onSelectAnnotation: ((RDPDFReaderAnnotation) -> Void)?
|
||||
var onDeleteAnnotation: ((RDPDFReaderAnnotation) -> Void)?
|
||||
var onDone: (() -> Void)?
|
||||
|
||||
private let annotationsProvider: () -> [RDPDFReaderAnnotation]
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "注释", "高亮"])
|
||||
private var annotations: [RDPDFReaderAnnotation] = []
|
||||
|
||||
init(annotationsProvider: @escaping () -> [RDPDFReaderAnnotation]) {
|
||||
self.annotationsProvider = annotationsProvider
|
||||
super.init(style: .plain)
|
||||
title = "笔记"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "pdf.reader.annotations.panel"
|
||||
tableView.accessibilityIdentifier = "pdf.reader.annotations.table"
|
||||
tableView.tableFooterView = UIView()
|
||||
filterControl.selectedSegmentIndex = 0
|
||||
filterControl.accessibilityIdentifier = "pdf.reader.annotations.filter"
|
||||
filterControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
|
||||
navigationItem.titleView = filterControl
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||||
reloadAnnotations()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
reloadAnnotations()
|
||||
}
|
||||
|
||||
private var filteredAnnotations: [RDPDFReaderAnnotation] {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1: return annotations.filter { !($0.note?.isEmpty ?? true) }
|
||||
case 2: return annotations.filter { $0.note?.isEmpty ?? true }
|
||||
default: return annotations
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadAnnotations() {
|
||||
annotations = annotationsProvider().sorted { $0.updatedAt > $1.updatedAt }
|
||||
tableView.reloadData()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filteredAnnotations.count }
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "annotation") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "annotation")
|
||||
let annotation = filteredAnnotations[indexPath.row]
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.detailTextLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = annotation.selectedText?.isEmpty == false ? annotation.selectedText : "区域标注"
|
||||
let note = annotation.note?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
cell.detailTextLabel?.text = ["第 \(annotation.pageIndex + 1) 页", note].compactMap { $0 }.joined(separator: " · ")
|
||||
cell.imageView?.image = UIImage(systemName: note?.isEmpty == false ? "note.text" : "highlighter")
|
||||
cell.imageView?.tintColor = UIColor(rdPDFHexString: annotation.color)
|
||||
cell.accessibilityIdentifier = "pdf.reader.annotations.row.\(annotation.id)"
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let annotation = filteredAnnotations[indexPath.row]
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
onSelectAnnotation?(annotation)
|
||||
}
|
||||
|
||||
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 }
|
||||
let annotation = filteredAnnotations[indexPath.row]
|
||||
onDeleteAnnotation?(annotation)
|
||||
reloadAnnotations()
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard filteredAnnotations.isEmpty else { tableView.backgroundView = nil; return }
|
||||
let label = UILabel()
|
||||
label.text = filterControl.selectedSegmentIndex == 1 ? "暂无注释" : "暂无标注"
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.accessibilityIdentifier = "pdf.reader.annotations.empty"
|
||||
tableView.backgroundView = label
|
||||
}
|
||||
|
||||
@objc private func filterChanged() { reloadAnnotations() }
|
||||
@objc private func doneAction() { onDone?() }
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
convenience init(rdPDFHexString: String) {
|
||||
let hex = rdPDFHexString.replacingOccurrences(of: "#", with: "")
|
||||
let value = UInt32(hex, radix: 16) ?? 0xF4C542
|
||||
self.init(
|
||||
red: CGFloat((value >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((value >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(value & 0xFF) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user