import UIKit import Foundation #if canImport(DTCoreText) import DTCoreText #endif 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 coverImageView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFit view.isHidden = true 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(coverImageView) 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) coverImageView.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)" if configureCoverIfNeeded(for: page) { textView.attributedText = nil textView.selectedRange = NSRange(location: 0, length: 0) delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() return } coverImageView.isHidden = true coverImageView.image = nil let displayContent = NSMutableAttributedString(attributedString: page.content) let fullRange = NSRange(location: 0, length: displayContent.length) displayContent.addAttribute( .foregroundColor, value: configuration.theme.contentTextColor, range: fullRange ) normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize) 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.. Bool { guard page.pageIndexInChapter == 0, page.href.lowercased().contains("cover"), let image = coverImage(from: page.content) else { return false } coverImageView.image = image coverImageView.isHidden = false textView.attributedText = nil return true } private func coverImage(from content: NSAttributedString) -> UIImage? { guard content.length > 0 else { return nil } var resolvedImage: UIImage? content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in guard let image = image(from: value) else { return } resolvedImage = image stop.pointee = true } return resolvedImage } private func image(from attachmentValue: Any?) -> UIImage? { #if canImport(DTCoreText) if let attachment = attachmentValue as? DTTextAttachment, let url = attachment.contentURL { return UIImage(contentsOfFile: url.path) } #endif if let attachment = attachmentValue as? NSTextAttachment { if let image = attachment.image { return image } if let data = attachment.contents { return UIImage(data: data) } if let fileWrapper = attachment.fileWrapper, let data = fileWrapper.regularFileContents { return UIImage(data: data) } } return nil } private func normalizeInlineAttachments(in content: NSMutableAttributedString, basePointSize: CGFloat) { guard content.length > 0 else { return } content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in #if canImport(DTCoreText) if let attachment = value as? DTTextAttachment { let classes = ((attachment.attributes["class"] as? String) ?? "").lowercased() let source = (attachment.contentURL?.lastPathComponent ?? (attachment.attributes["src"] as? String) ?? "").lowercased() guard classes.contains("qqreader-footnote") || source.contains("note.png") else { return } let pointSize = max(basePointSize, 1) let targetHeight = max(round(pointSize * 0.14), 1) let aspectRatio = attachment.originalSize.height > 0 ? attachment.originalSize.width / attachment.originalSize.height : 1 let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1) attachment.displaySize = CGSize(width: targetWidth, height: targetHeight) attachment.verticalAlignment = .center content.addAttribute(.attachment, value: attachment, range: range) } #endif } } } 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 ) } }