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 pageRange = absoluteOffsetRange(for: page) let pageStart = pageRange.lowerBound let pageEndExclusive = pageRange.upperBound 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 pageRange = absoluteOffsetRange(for: page) let pageStart = pageRange.lowerBound let pageEndExclusive = pageRange.upperBound 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) } } private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range { let lowerBound = page.pageStartOffset let upperBound = page.pageEndOffset + 1 return lowerBound.. 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 ) } }