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: ["全部", "注释", "划线"]) /// 空状态提示。放在 tableView 的子视图而不是 `backgroundView`: /// backgroundView 不参与无障碍层级遍历,VoiceOver 与 UI 测试都读不到它。 private let emptyStateLabel = UILabel() private var filteredHighlights: [RDEPUBHighlight] { switch filterControl.selectedSegmentIndex { case 1: return highlights.filter(\.hasNote) case 2: return highlights.filter { !$0.hasNote } 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() view.accessibilityIdentifier = "epub.reader.highlights.panel" tableView.accessibilityIdentifier = "epub.reader.highlights.table" tableView.tableFooterView = UIView(frame: .zero) 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() } 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 = .clear cell.contentView.backgroundColor = theme.contentBackgroundColor.withAlphaComponent(0.76) cell.contentView.layer.cornerRadius = 14 cell.contentView.layer.masksToBounds = true 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.65) cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) cell.detailTextLabel?.numberOfLines = 3 cell.detailTextLabel?.text = detailText(for: highlight) 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) 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() { filterControl.selectedSegmentIndex = 0 filterControl.accessibilityIdentifier = "epub.reader.highlights.filter" filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged) navigationItem.titleView = filterControl } private func applyTheme() { tableView.backgroundColor = theme.toolBackgroundColor navigationController?.navigationBar.tintColor = .systemBlue navigationController?.navigationBar.barTintColor = tableView.backgroundColor navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar" } private func installEmptyStateLabelIfNeeded() { guard emptyStateLabel.superview == nil else { return } emptyStateLabel.textAlignment = .center emptyStateLabel.numberOfLines = 0 emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false emptyStateLabel.accessibilityIdentifier = "epub.reader.highlights.empty" emptyStateLabel.isAccessibilityElement = true tableView.addSubview(emptyStateLabel) // 固定在可视区域居中,不随内容滚动 NSLayoutConstraint.activate([ emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor), emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor), emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24), emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24) ]) } private func updateEmptyState() { installEmptyStateLabelIfNeeded() emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7) emptyStateLabel.text = emptyStateText() emptyStateLabel.isHidden = !filteredHighlights.isEmpty } @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 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 }() /// 同 highlights 面板:不能用 `tableView.backgroundView`,那样无障碍读不到。 private let emptyStateLabel = UILabel() init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) { self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt } self.theme = theme super.init(style: .plain) title = "书签" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.accessibilityIdentifier = "epub.reader.bookmarks.panel" tableView.accessibilityIdentifier = "epub.reader.bookmarks.table" tableView.tableFooterView = UIView(frame: .zero) tableView.separatorStyle = .none tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0) navigationItem.rightBarButtonItem = editButtonItem editButtonItem.title = "编辑" 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 = .clear cell.contentView.backgroundColor = theme.contentBackgroundColor.withAlphaComponent(0.76) cell.contentView.layer.cornerRadius = 14 cell.contentView.layer.masksToBounds = true 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.65) cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) cell.detailTextLabel?.numberOfLines = 3 cell.detailTextLabel?.text = detailText(for: bookmark) 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) onSelectBookmark?(bookmarks[indexPath.row]) } private func applyTheme() { tableView.backgroundColor = theme.toolBackgroundColor navigationController?.navigationBar.tintColor = .systemBlue navigationController?.navigationBar.barTintColor = tableView.backgroundColor navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar" } private func installEmptyStateLabelIfNeeded() { guard emptyStateLabel.superview == nil else { return } emptyStateLabel.text = "暂无书签" emptyStateLabel.textAlignment = .center emptyStateLabel.numberOfLines = 0 emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false emptyStateLabel.accessibilityIdentifier = "epub.reader.bookmarks.empty" emptyStateLabel.isAccessibilityElement = true tableView.addSubview(emptyStateLabel) NSLayoutConstraint.activate([ emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor), emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor), emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24), emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24) ]) } private func updateEmptyState() { installEmptyStateLabelIfNeeded() emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7) emptyStateLabel.isHidden = !bookmarks.isEmpty } 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 deleteBookmark(_ bookmark: RDEPUBBookmark) { guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return } bookmarks.remove(at: index) tableView.reloadData() onDeleteBookmark?(bookmark) updateEmptyState() } }