486 lines
18 KiB
Swift
486 lines
18 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)
|
|
}
|
|
}
|
|
|
|
#if canImport(DTCoreText)
|
|
final class RDEPUBDirectCoreTextPageView: UIView {
|
|
var layoutFrame: DTCoreTextLayoutFrame? {
|
|
didSet {
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
|
|
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
|
|
didSet {
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
contentMode = .redraw
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
guard let context = UIGraphicsGetCurrentContext(),
|
|
let layoutFrame else { return }
|
|
|
|
context.saveGState()
|
|
layoutFrame.draw(in: context, options: drawOptions)
|
|
context.restoreGState()
|
|
}
|
|
}
|
|
#endif
|
|
|
|
final class RDEPUBTextContentView: UIView {
|
|
private var contentInsets: UIEdgeInsets = .zero
|
|
private var currentPage: RDEPUBTextPage?
|
|
private var highlightedRanges: [RDEPUBHighlight] = []
|
|
weak var delegate: RDEPUBTextContentViewDelegate?
|
|
|
|
#if canImport(DTCoreText)
|
|
private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
|
|
let view = RDEPUBDirectCoreTextPageView()
|
|
view.backgroundColor = .clear
|
|
view.isOpaque = false
|
|
return view
|
|
}()
|
|
|
|
private var coreTextDisplayContent: NSAttributedString?
|
|
private var coreTextDisplayRange: NSRange?
|
|
#endif
|
|
|
|
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)
|
|
#if canImport(DTCoreText)
|
|
addSubview(coreTextContentView)
|
|
#endif
|
|
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()
|
|
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.frame = bounds.inset(by: contentInsets)
|
|
updateCoreTextLayoutFrameIfNeeded()
|
|
#endif
|
|
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) {
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.isHidden = true
|
|
coreTextContentView.layoutFrame = nil
|
|
coreTextDisplayContent = nil
|
|
coreTextDisplayRange = nil
|
|
#endif
|
|
textView.attributedText = nil
|
|
textView.selectedRange = NSRange(location: 0, length: 0)
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
setNeedsLayout()
|
|
return
|
|
}
|
|
|
|
coverImageView.isHidden = true
|
|
coverImageView.image = nil
|
|
|
|
let selectionContent = NSMutableAttributedString(attributedString: page.content)
|
|
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
|
selectionContent.addAttribute(
|
|
.foregroundColor,
|
|
value: configuration.theme.contentTextColor,
|
|
range: selectionRange
|
|
)
|
|
normalizeInlineAttachments(in: selectionContent, basePointSize: configuration.fontSize)
|
|
|
|
#if canImport(DTCoreText)
|
|
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
|
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, contentBaseOffset: 0)
|
|
applySearchHighlights(to: displayContent, page: page, searchState: searchState, contentBaseOffset: 0)
|
|
coreTextContentView.isHidden = false
|
|
coreTextContentView.backgroundColor = .clear
|
|
coreTextDisplayContent = displayContent
|
|
coreTextDisplayRange = page.contentRange
|
|
updateCoreTextLayoutFrameIfNeeded()
|
|
#else
|
|
applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
|
applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
|
#endif
|
|
|
|
textView.tintColor = configuration.theme.toolControlTextColor
|
|
textView.attributedText = selectionProxyContent(from: selectionContent)
|
|
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) {
|
|
applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
|
|
}
|
|
|
|
private func applyHighlights(
|
|
to content: NSMutableAttributedString,
|
|
page: RDEPUBTextPage,
|
|
contentBaseOffset: Int
|
|
) {
|
|
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 - contentBaseOffset,
|
|
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?
|
|
) {
|
|
applySearchHighlights(
|
|
to: content,
|
|
page: page,
|
|
searchState: searchState,
|
|
contentBaseOffset: page.pageStartOffset
|
|
)
|
|
}
|
|
|
|
private func applySearchHighlights(
|
|
to content: NSMutableAttributedString,
|
|
page: RDEPUBTextPage,
|
|
searchState: RDEPUBSearchState?,
|
|
contentBaseOffset: Int
|
|
) {
|
|
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 - contentBaseOffset), 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
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.isHidden = true
|
|
coreTextContentView.layoutFrame = nil
|
|
coreTextDisplayContent = nil
|
|
coreTextDisplayRange = nil
|
|
#endif
|
|
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 {
|
|
RDEPUBTextRendererSupport.normalizeAttachmentLayoutForWXRead(
|
|
attachment,
|
|
fontPointSize: basePointSize
|
|
)
|
|
content.addAttribute(.attachment, value: attachment, range: range)
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
|
let proxy = NSMutableAttributedString(attributedString: content)
|
|
let fullRange = NSRange(location: 0, length: proxy.length)
|
|
proxy.removeAttribute(.backgroundColor, range: fullRange)
|
|
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
|
|
|
|
var attachmentRanges: [NSRange] = []
|
|
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
|
guard value != nil else { return }
|
|
attachmentRanges.append(range)
|
|
}
|
|
|
|
for range in attachmentRanges.reversed() {
|
|
let replacement = NSAttributedString(
|
|
string: String(repeating: " ", count: max(range.length, 1)),
|
|
attributes: [
|
|
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
|
|
.foregroundColor: UIColor.clear
|
|
]
|
|
)
|
|
proxy.replaceCharacters(in: range, with: replacement)
|
|
}
|
|
|
|
return proxy
|
|
}
|
|
|
|
#if canImport(DTCoreText)
|
|
private func updateCoreTextLayoutFrameIfNeeded() {
|
|
guard !coreTextContentView.isHidden,
|
|
let displayContent = coreTextDisplayContent,
|
|
let displayRange = coreTextDisplayRange,
|
|
coreTextContentView.bounds.width > 0,
|
|
coreTextContentView.bounds.height > 0 else {
|
|
return
|
|
}
|
|
|
|
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
|
|
coreTextContentView.layoutFrame = nil
|
|
return
|
|
}
|
|
layouter.shouldCacheLayoutFrames = false
|
|
coreTextContentView.layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
|
}
|
|
#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
|
|
)
|
|
}
|
|
}
|