Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
var onShowBookmarks: (() -> Void)?
|
||||
var onShowHighlights: (() -> Void)?
|
||||
var onAddHighlight: (() -> Void)?
|
||||
var onShowSettings: (() -> Void)?
|
||||
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
view.distribution = .fillEqually
|
||||
view.alignment = .fill
|
||||
view.spacing = 16
|
||||
return view
|
||||
}()
|
||||
|
||||
private let chapterButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let settingsButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(chapterButton)
|
||||
stackView.addArrangedSubview(bookmarksButton)
|
||||
stackView.addArrangedSubview(highlightsButton)
|
||||
stackView.addArrangedSubview(addHighlightButton)
|
||||
stackView.addArrangedSubview(settingsButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
|
||||
stackView.topAnchor.constraint(equalTo: topAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
|
||||
])
|
||||
|
||||
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
|
||||
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
||||
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
|
||||
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
||||
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
||||
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
||||
bookmarksButton.accessibilityIdentifier = "epub.reader.bookmarks"
|
||||
highlightsButton.accessibilityIdentifier = "epub.reader.highlights"
|
||||
addHighlightButton.accessibilityIdentifier = "epub.reader.add-highlight"
|
||||
settingsButton.accessibilityIdentifier = "epub.reader.settings"
|
||||
|
||||
[chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
|
||||
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
|
||||
}
|
||||
|
||||
chapterButton.addTarget(self, action: #selector(chapterAction), for: .touchUpInside)
|
||||
bookmarksButton.addTarget(self, action: #selector(bookmarksAction), for: .touchUpInside)
|
||||
highlightsButton.addTarget(self, action: #selector(highlightsAction), for: .touchUpInside)
|
||||
addHighlightButton.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
|
||||
settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: 0.5)
|
||||
}
|
||||
|
||||
override func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
[chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
|
||||
button.tintColor = .black
|
||||
button.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func setAddHighlightEnabled(_ isEnabled: Bool) {
|
||||
addHighlightButton.isEnabled = isEnabled
|
||||
addHighlightButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
func setHighlightsEnabled(_ isEnabled: Bool) {
|
||||
highlightsButton.isEnabled = isEnabled
|
||||
highlightsButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
func setBookmarksEnabled(_ isEnabled: Bool) {
|
||||
bookmarksButton.isEnabled = isEnabled
|
||||
bookmarksButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
func updateVisibility(
|
||||
showsTableOfContents: Bool,
|
||||
allowsHighlights: Bool,
|
||||
showsSettingsPanel: Bool
|
||||
) {
|
||||
chapterButton.isHidden = !showsTableOfContents
|
||||
bookmarksButton.isHidden = false
|
||||
highlightsButton.isHidden = !allowsHighlights
|
||||
addHighlightButton.isHidden = !allowsHighlights
|
||||
settingsButton.isHidden = !showsSettingsPanel
|
||||
}
|
||||
|
||||
private func configureButton(_ button: UIButton, systemName: String, fallbackTitle: String) {
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
if #available(iOS 13.0, *), let image = UIImage(systemName: systemName) {
|
||||
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
button.setTitle(fallbackTitle, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func chapterAction() {
|
||||
onShowTableOfContents?()
|
||||
}
|
||||
|
||||
@objc private func bookmarksAction() {
|
||||
onShowBookmarks?()
|
||||
}
|
||||
|
||||
@objc private func highlightsAction() {
|
||||
onShowHighlights?()
|
||||
}
|
||||
|
||||
@objc private func highlightAction() {
|
||||
onAddHighlight?()
|
||||
}
|
||||
|
||||
@objc private func settingsAction() {
|
||||
onShowSettings?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
|
||||
|
||||
private let items: [RDEPUBReaderTableOfContentsItem]
|
||||
private let currentItem: RDEPUBReaderTableOfContentsItem?
|
||||
private let theme: RDEPUBReaderTheme
|
||||
|
||||
init(
|
||||
items: [RDEPUBReaderTableOfContentsItem],
|
||||
currentItem: RDEPUBReaderTableOfContentsItem?,
|
||||
theme: RDEPUBReaderTheme
|
||||
) {
|
||||
self.items = items
|
||||
self.currentItem = currentItem
|
||||
self.theme = theme
|
||||
super.init(style: .plain)
|
||||
title = "目录"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let item = items[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = item.title
|
||||
cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor
|
||||
cell.indentationLevel = item.depth
|
||||
cell.indentationWidth = 18
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
onSelectItem?(items[indexPath.row])
|
||||
}
|
||||
|
||||
private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
|
||||
guard let currentItem else { return false }
|
||||
return currentItem.href == item.href
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
public var lineHeightMultiple: CGFloat
|
||||
public var displayType: RDReaderView.DisplayType
|
||||
public var landscapeDualPageEnabled: Bool
|
||||
public var showsTableOfContents: Bool
|
||||
public var allowsHighlights: Bool
|
||||
public var showsSettingsPanel: Bool
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
public var theme: RDEPUBReaderTheme
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
public var textRenderingEngine: RDEPUBTextRenderingEngine
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
displayType: RDReaderView.DisplayType = .pageCurl,
|
||||
landscapeDualPageEnabled: Bool = true,
|
||||
showsTableOfContents: Bool = true,
|
||||
allowsHighlights: Bool = true,
|
||||
showsSettingsPanel: Bool = true,
|
||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
||||
fixedContentInset: UIEdgeInsets = .zero,
|
||||
theme: RDEPUBReaderTheme = .light,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.displayType = displayType
|
||||
self.landscapeDualPageEnabled = landscapeDualPageEnabled
|
||||
self.showsTableOfContents = showsTableOfContents
|
||||
self.allowsHighlights = allowsHighlights
|
||||
self.showsSettingsPanel = showsSettingsPanel
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.theme = theme
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
}
|
||||
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
func makePreferences() -> RDEPUBPreferences {
|
||||
RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
reflowableContentInsets: reflowableContentInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
themeBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
themeTextColor: theme.themeTextColorCSS,
|
||||
fixedBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
fixedLayoutFit: fixedLayoutFit,
|
||||
fixedLayoutSpreadMode: fixedLayoutSpreadMode
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
import UIKit
|
||||
|
||||
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
|
||||
public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController) {}
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) {}
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
|
||||
private var highlights: [RDEPUBHighlight]
|
||||
private let theme: RDEPUBReaderTheme
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
return highlights.filter(\.hasNote)
|
||||
case 2:
|
||||
return highlights.filter { $0.style == .highlight }
|
||||
default:
|
||||
return highlights
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
highlights: [RDEPUBHighlight],
|
||||
theme: RDEPUBReaderTheme,
|
||||
sectionTitleProvider: @escaping (RDEPUBHighlight) -> String?
|
||||
) {
|
||||
self.highlights = highlights.sorted { $0.createdAt > $1.createdAt }
|
||||
self.theme = theme
|
||||
self.sectionTitleProvider = sectionTitleProvider
|
||||
super.init(style: .plain)
|
||||
title = "标注"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
configureFilterControl()
|
||||
applyTheme()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
filteredHighlights.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cellIdentifier = "HighlightCell"
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let highlight = filteredHighlights[indexPath.row]
|
||||
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = titleText(for: highlight)
|
||||
|
||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||
cell.detailTextLabel?.numberOfLines = 3
|
||||
cell.detailTextLabel?.text = detailText(for: highlight)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
|
||||
}
|
||||
|
||||
private func configureFilterControl() {
|
||||
filterControl.selectedSegmentIndex = 0
|
||||
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
|
||||
navigationItem.titleView = filterControl
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard filteredHighlights.isEmpty else {
|
||||
tableView.backgroundView = nil
|
||||
return
|
||||
}
|
||||
|
||||
let label = UILabel()
|
||||
label.text = emptyStateText()
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
tableView.backgroundView = label
|
||||
}
|
||||
|
||||
@objc private func filterChangedAction() {
|
||||
tableView.reloadData()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
private func emptyStateText() -> String {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
return "暂无批注"
|
||||
case 2:
|
||||
return "暂无划线"
|
||||
default:
|
||||
return "暂无标注"
|
||||
}
|
||||
}
|
||||
|
||||
private func detailText(for highlight: RDEPUBHighlight) -> String {
|
||||
let style = styleDescription(for: highlight)
|
||||
let chapter = sectionTitleProvider(highlight)
|
||||
let note = normalizedNote(highlight.note)
|
||||
let parts = [style, chapter, note].compactMap { $0 }
|
||||
if !parts.isEmpty {
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
return highlight.location.href
|
||||
}
|
||||
|
||||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if highlight.hasNote {
|
||||
return "批注: \(text)"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
return highlight.hasNote ? "高亮批注" : "高亮"
|
||||
case .underline:
|
||||
return highlight.hasNote ? "划线批注" : "划线"
|
||||
}
|
||||
}
|
||||
|
||||
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
|
||||
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
||||
self?.onSelectHighlight?(highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
|
||||
self?.presentNoteEditor(for: highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
|
||||
self?.deleteHighlight(highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController,
|
||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
||||
popover.sourceView = cell
|
||||
popover.sourceRect = cell.bounds
|
||||
}
|
||||
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
|
||||
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "输入批注内容"
|
||||
textField.text = highlight.note
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||||
guard let self,
|
||||
let note = alert?.textFields?.first?.text,
|
||||
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var updated = highlight
|
||||
updated.note = self.normalizedNote(note)
|
||||
self.highlights[index] = updated
|
||||
self.tableView.reloadData()
|
||||
self.onUpdateHighlight?(updated)
|
||||
self.updateEmptyState()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
||||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||||
highlights.remove(at: index)
|
||||
tableView.reloadData()
|
||||
onDeleteHighlight?(highlight)
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
private func normalizedNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
|
||||
private var bookmarks: [RDEPUBBookmark]
|
||||
private let theme: RDEPUBReaderTheme
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
|
||||
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
|
||||
self.theme = theme
|
||||
super.init(style: .plain)
|
||||
title = "书签"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
applyTheme()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
bookmarks.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cellIdentifier = "BookmarkCell"
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let bookmark = bookmarks[indexPath.row]
|
||||
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = titleText(for: bookmark)
|
||||
|
||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||
cell.detailTextLabel?.numberOfLines = 3
|
||||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard bookmarks.isEmpty == false else {
|
||||
let label = UILabel()
|
||||
label.text = "暂无书签"
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
tableView.backgroundView = label
|
||||
return
|
||||
}
|
||||
tableView.backgroundView = nil
|
||||
}
|
||||
|
||||
private func titleText(for bookmark: RDEPUBBookmark) -> String {
|
||||
if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty {
|
||||
return chapterTitle
|
||||
}
|
||||
return bookmark.location.href
|
||||
}
|
||||
|
||||
private func detailText(for bookmark: RDEPUBBookmark) -> String {
|
||||
let parts = [
|
||||
dateFormatter.string(from: bookmark.createdAt),
|
||||
bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
bookmark.location.href
|
||||
].compactMap { value -> String? in
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
|
||||
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
||||
self?.onSelectBookmark?(bookmark)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
|
||||
self?.deleteBookmark(bookmark)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController,
|
||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
||||
popover.sourceView = cell
|
||||
popover.sourceRect = cell.bounds
|
||||
}
|
||||
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
||||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||||
bookmarks.remove(at: index)
|
||||
tableView.reloadData()
|
||||
onDeleteBookmark?(bookmark)
|
||||
updateEmptyState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||
}
|
||||
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
_ = bookIdentifier
|
||||
return []
|
||||
}
|
||||
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
_ = bookmarks
|
||||
_ = bookIdentifier
|
||||
}
|
||||
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
nil
|
||||
}
|
||||
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||
_ = settings
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
private let defaults: UserDefaults
|
||||
private let locationPrefix: String
|
||||
private let bookmarksPrefix: String
|
||||
private let highlightsPrefix: String
|
||||
private let settingsKey: String
|
||||
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
bookmarksPrefix: String = "ssreader.epub.bookmarks.",
|
||||
highlightsPrefix: String = "ssreader.epub.highlights.",
|
||||
settingsKey: String = "ssreader.epub.settings"
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.locationPrefix = locationPrefix
|
||||
self.bookmarksPrefix = bookmarksPrefix
|
||||
self.highlightsPrefix = highlightsPrefix
|
||||
self.settingsKey = settingsKey
|
||||
}
|
||||
|
||||
public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
|
||||
guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(RDEPUBLocation.self, from: data)
|
||||
}
|
||||
|
||||
public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(location) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: locationPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
return (try? JSONDecoder().decode([RDEPUBBookmark].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(bookmarks) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
|
||||
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(highlights) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
guard let data = defaults.data(forKey: settingsKey) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
|
||||
}
|
||||
|
||||
public func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||
guard let data = try? JSONEncoder().encode(settings) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: settingsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
case pageCurl
|
||||
case horizontalScroll
|
||||
case verticalScroll
|
||||
case horizontalCoverScroll
|
||||
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
self = .pageCurl
|
||||
case .horizontalScroll:
|
||||
self = .horizontalScroll
|
||||
case .verticalScroll:
|
||||
self = .verticalScroll
|
||||
}
|
||||
}
|
||||
|
||||
var displayType: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return .pageCurl
|
||||
case .horizontalScroll, .horizontalCoverScroll:
|
||||
return .horizontalScroll
|
||||
case .verticalScroll:
|
||||
return .verticalScroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
case light
|
||||
case yellow
|
||||
case green
|
||||
case pink
|
||||
case blue
|
||||
case dark
|
||||
|
||||
public var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light:
|
||||
return .light
|
||||
case .yellow:
|
||||
return .yellow
|
||||
case .green:
|
||||
return .green
|
||||
case .pink:
|
||||
return .pink
|
||||
case .blue:
|
||||
return .blue
|
||||
case .dark:
|
||||
return .dark
|
||||
}
|
||||
}
|
||||
|
||||
public init?(theme: RDEPUBReaderTheme) {
|
||||
switch theme {
|
||||
case .light:
|
||||
self = .light
|
||||
case .yellow:
|
||||
self = .yellow
|
||||
case .green:
|
||||
self = .green
|
||||
case .pink:
|
||||
self = .pink
|
||||
case .blue:
|
||||
self = .blue
|
||||
case .dark:
|
||||
self = .dark
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
public var brightness: CGFloat?
|
||||
public var fontSize: CGFloat?
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
public var themePreset: RDEPUBReaderThemePreset?
|
||||
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
lineHeightMultiple: CGFloat? = nil,
|
||||
displayMode: RDEPUBReaderDisplayMode? = nil,
|
||||
themePreset: RDEPUBReaderThemePreset? = nil
|
||||
) {
|
||||
self.brightness = brightness
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.displayMode = displayMode
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
|
||||
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
|
||||
var resolvedConfiguration = configuration
|
||||
|
||||
if let fontSize {
|
||||
resolvedConfiguration.fontSize = fontSize
|
||||
}
|
||||
if let lineHeightMultiple {
|
||||
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
|
||||
}
|
||||
if let displayMode {
|
||||
resolvedConfiguration.displayType = displayMode.displayType
|
||||
}
|
||||
if let themePreset {
|
||||
resolvedConfiguration.theme = themePreset.theme
|
||||
}
|
||||
|
||||
return resolvedConfiguration
|
||||
}
|
||||
|
||||
public static func capture(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
brightness: CGFloat
|
||||
) -> RDEPUBReaderSettings {
|
||||
RDEPUBReaderSettings(
|
||||
brightness: max(0, min(1, brightness)),
|
||||
fontSize: configuration.fontSize,
|
||||
lineHeightMultiple: configuration.lineHeightMultiple,
|
||||
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
|
||||
themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
|
||||
private enum ThemePreset: Int, CaseIterable {
|
||||
case light
|
||||
case yellow
|
||||
case green
|
||||
case pink
|
||||
case blue
|
||||
case dark
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .light: return "浅色"
|
||||
case .yellow: return "米黄"
|
||||
case .green: return "青绿"
|
||||
case .pink: return "粉色"
|
||||
case .blue: return "蓝灰"
|
||||
case .dark: return "夜间"
|
||||
}
|
||||
}
|
||||
|
||||
var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light: return .light
|
||||
case .yellow: return .yellow
|
||||
case .green: return .green
|
||||
case .pink: return .pink
|
||||
case .blue: return .blue
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
private let brightnessSlider = UISlider()
|
||||
private let fontValueLabel = UILabel()
|
||||
private let decreaseFontButton = UIButton(type: .system)
|
||||
private let increaseFontButton = UIButton(type: .system)
|
||||
private let lineHeightControl = 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 var themeButtons: [UIButton] = []
|
||||
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||
|
||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||
self.currentConfiguration = configuration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
brightnessSlider.value = Float(brightness)
|
||||
title = "样式设置"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupNavigationItems()
|
||||
setupViews()
|
||||
syncControls()
|
||||
applyTheme(currentConfiguration.theme)
|
||||
}
|
||||
|
||||
private func setupNavigationItems() {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
view.addSubview(scrollView)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
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),
|
||||
|
||||
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)
|
||||
])
|
||||
|
||||
brightnessSlider.minimumValue = 0
|
||||
brightnessSlider.maximumValue = 1
|
||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||
|
||||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
||||
fontValueLabel.textAlignment = .center
|
||||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
configureFontButton(decreaseFontButton, title: "A-")
|
||||
configureFontButton(increaseFontButton, title: "A+")
|
||||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
||||
|
||||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
||||
|
||||
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.accessibilityLabel = preset.title
|
||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||
themeButtons.append(button)
|
||||
themeStackView.addArrangedSubview(button)
|
||||
NSLayoutConstraint.activate([
|
||||
button.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
}
|
||||
|
||||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
||||
}
|
||||
|
||||
private func makeSection(title: String, content: UIView) -> UIView {
|
||||
let container = UIStackView()
|
||||
container.axis = .vertical
|
||||
container.spacing = 10
|
||||
|
||||
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
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
||||
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
||||
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
return stackView
|
||||
}
|
||||
|
||||
private func configureFontButton(_ 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
|
||||
}
|
||||
|
||||
private func syncControls() {
|
||||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||
|
||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
||||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
||||
|
||||
switch currentConfiguration.displayType {
|
||||
case .pageCurl:
|
||||
displayTypeControl.selectedSegmentIndex = 0
|
||||
case .horizontalScroll:
|
||||
displayTypeControl.selectedSegmentIndex = 1
|
||||
case .verticalScroll:
|
||||
displayTypeControl.selectedSegmentIndex = 2
|
||||
}
|
||||
|
||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
||||
updateThemeSelection(selectedPreset)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
[lineHeightControl, 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]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func doneAction() {
|
||||
dismiss(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 lineHeightChanged(_ control: UISegmentedControl) {
|
||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||||
let value = lineHeightValues[index]
|
||||
currentConfiguration.lineHeightMultiple = value
|
||||
onLineHeightChange?(value)
|
||||
}
|
||||
|
||||
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
switch control.selectedSegmentIndex {
|
||||
case 1:
|
||||
displayType = .horizontalScroll
|
||||
case 2:
|
||||
displayType = .verticalScroll
|
||||
default:
|
||||
displayType = .pageCurl
|
||||
}
|
||||
currentConfiguration.displayType = displayType
|
||||
onDisplayTypeChange?(displayType)
|
||||
}
|
||||
|
||||
@objc private func themeButtonAction(_ sender: UIButton) {
|
||||
guard let preset = ThemePreset(rawValue: sender.tag) else { return }
|
||||
currentConfiguration.theme = preset.theme
|
||||
applyTheme(preset.theme)
|
||||
onThemeChange?(preset.theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
public var title: String
|
||||
public var href: String
|
||||
public var depth: Int
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
self.title = title
|
||||
self.href = href
|
||||
self.depth = depth
|
||||
self.pageNumber = pageNumber
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBReaderTheme: Equatable {
|
||||
public var contentBackgroundColor: UIColor
|
||||
public var contentTextColor: UIColor
|
||||
public var toolBackgroundColor: UIColor
|
||||
public var toolControlTextColor: UIColor
|
||||
public var toolControlBorderUnselectColor: UIColor
|
||||
public var toolLineColor: UIColor
|
||||
|
||||
public init(
|
||||
contentBackgroundColor: UIColor,
|
||||
contentTextColor: UIColor,
|
||||
toolBackgroundColor: UIColor,
|
||||
toolControlTextColor: UIColor,
|
||||
toolControlBorderUnselectColor: UIColor,
|
||||
toolLineColor: UIColor
|
||||
) {
|
||||
self.contentBackgroundColor = contentBackgroundColor
|
||||
self.contentTextColor = contentTextColor
|
||||
self.toolBackgroundColor = toolBackgroundColor
|
||||
self.toolControlTextColor = toolControlTextColor
|
||||
self.toolControlBorderUnselectColor = toolControlBorderUnselectColor
|
||||
self.toolLineColor = toolLineColor
|
||||
}
|
||||
|
||||
public static let light = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .white,
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: .white,
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let dark = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .black,
|
||||
contentTextColor: .white,
|
||||
toolBackgroundColor: .black,
|
||||
toolControlTextColor: .white,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let yellow = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let green = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let pink = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let blue = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
}
|
||||
|
||||
extension RDEPUBReaderTheme {
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.ss_cssString
|
||||
}
|
||||
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.ss_cssString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import UIKit
|
||||
|
||||
open class RDEPUBReaderToolView: UIView {
|
||||
private let lineView = UIView()
|
||||
private let lineHeight: CGFloat = 0.5
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(lineView)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
open override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = lineFrame(in: bounds)
|
||||
}
|
||||
|
||||
open func apply(theme: RDEPUBReaderTheme) {
|
||||
backgroundColor = .white
|
||||
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
open func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBReaderTintButton: UIButton {
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
var onBack: (() -> Void)?
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textAlignment = .center
|
||||
label.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||||
label.numberOfLines = 1
|
||||
return label
|
||||
}()
|
||||
private var isBookmarked = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.backgroundColor = .white
|
||||
addSubview(backButton)
|
||||
addSubview(bookmarkButton)
|
||||
addSubview(titleLabel)
|
||||
|
||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||||
backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
|
||||
backButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
bookmarkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
bookmarkButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
|
||||
bookmarkButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
||||
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
|
||||
])
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
backButton.setTitle("返回", for: .normal)
|
||||
}
|
||||
backButton.accessibilityIdentifier = "epub.reader.back"
|
||||
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
|
||||
titleLabel.accessibilityIdentifier = "epub.reader.title"
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||||
}
|
||||
|
||||
override public func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
titleLabel.textColor = .black
|
||||
backButton.tintColor = .black
|
||||
bookmarkButton.tintColor = .black
|
||||
if #unavailable(iOS 13.0) {
|
||||
backButton.setTitleColor(.black, for: .normal)
|
||||
bookmarkButton.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
func setTitle(_ title: String?) {
|
||||
titleLabel.text = title
|
||||
}
|
||||
|
||||
func setBookmarkSelected(_ isSelected: Bool) {
|
||||
isBookmarked = isSelected
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
func setBookmarkEnabled(_ isEnabled: Bool) {
|
||||
bookmarkButton.isEnabled = isEnabled
|
||||
bookmarkButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
onBack?()
|
||||
}
|
||||
|
||||
@objc private func bookmarkAction() {
|
||||
onToggleBookmark?()
|
||||
}
|
||||
|
||||
private func updateBookmarkButtonAppearance() {
|
||||
if #available(iOS 13.0, *) {
|
||||
let imageName = isBookmarked ? "bookmark.fill" : "bookmark"
|
||||
bookmarkButton.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectedRange.location != NSNotFound && selectedRange.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
onSelectionAction?(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
onSelectionAction?(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
onSelectionAction?(.annotate)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var highlightedRanges: [RDEPUBHighlight] = []
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
return view
|
||||
}()
|
||||
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = self
|
||||
textView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBSelectableTextView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBSelectableTextView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBSelectableTextView.rd_annotate(_:)))
|
||||
]
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
textView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
highlightedRanges = highlights
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
applyHighlights(to: displayContent, page: page)
|
||||
applySearchHighlights(to: displayContent, page: page, searchState: searchState)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = displayContent
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
}
|
||||
|
||||
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
|
||||
let pageStart = Int(page.pageStartOffset)
|
||||
let pageEndExclusive = Int(page.pageEndOffset) + 1
|
||||
|
||||
for highlight in highlightedRanges where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(
|
||||
location: overlapStart - pageStart,
|
||||
length: overlapEnd - overlapStart
|
||||
)
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.backgroundColor,
|
||||
value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||
range: relativeRange
|
||||
)
|
||||
case .underline:
|
||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||
if let color = UIColor(hexString: highlight.color, alpha: 1) {
|
||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
let pageStart = Int(page.pageStartOffset)
|
||||
let pageEndExclusive = Int(page.pageEndOffset) + 1
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - pageStart), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
guard let page = currentPage else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
guard selectedRange.location != NSNotFound,
|
||||
selectedRange.length > 0,
|
||||
let attributedText = textView.attributedText else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let source = attributedText.string as NSString
|
||||
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let globalStart = page.pageStartOffset + selectedRange.location
|
||||
let globalEnd = globalStart + selectedRange.length
|
||||
let totalLength = max(page.content.length - 1, 1)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(selectedRange.location) / Double(totalLength),
|
||||
lastProgression: Double(max(selectedRange.location + selectedRange.length - 1, 0)) / Double(totalLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
)
|
||||
delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
convenience init?(hexString: String, alpha: CGFloat) {
|
||||
var value = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value = value.replacingOccurrences(of: "#", with: "")
|
||||
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(hex & 0xFF) / 255,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import UIKit
|
||||
|
||||
protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
final class RDEPUBWebContentView: UIView {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
private let epubWebView = RDEPUBWebView()
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.masksToBounds = true
|
||||
|
||||
addSubview(epubWebView)
|
||||
addSubview(pageNumberLabel)
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
epubWebView.frame = bounds
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBRenderRequest,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
theme: RDEPUBReaderTheme
|
||||
) {
|
||||
backgroundColor = theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
epubWebView.load(publication: publication, request: request)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
epubWebView.reset()
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
delegate?.epubWebContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
delegate?.epubWebContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
delegate?.epubWebContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
delegate?.epubWebContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubWebContentView(self, didActivateExternalLink: url)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
||||
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
||||
}
|
||||
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
|
||||
}
|
||||
Reference in New Issue
Block a user