338 lines
13 KiB
Swift
338 lines
13 KiB
Swift
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<Int> {
|
|
let lowerBound = page.pageStartOffset
|
|
let upperBound = page.pageEndOffset + 1
|
|
return lowerBound..<max(upperBound, lowerBound)
|
|
}
|
|
|
|
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> 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
|
|
)
|
|
}
|
|
}
|