- 移除 RDEPUBSelectableTextView,改用原生 UITextView - 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮 - RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区 - RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制 - 新增高亮选区复刻 WXRead 实现方案文档 - UI 测试适配新架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
156 lines
7.3 KiB
Swift
156 lines
7.3 KiB
Swift
import UIKit
|
||
|
||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
||
/// - Parameters:
|
||
/// - highlights: 高亮批注数组
|
||
/// - content: 待修改的富文本
|
||
/// - page: 目标文本页
|
||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||
func applyHighlights(
|
||
_ highlights: [RDEPUBHighlight],
|
||
to content: NSMutableAttributedString,
|
||
page: RDEPUBTextPage,
|
||
contentBaseOffset: Int
|
||
) {
|
||
let pageRange = absoluteOffsetRange(for: page)
|
||
let pageStart = pageRange.lowerBound
|
||
let pageEndExclusive = pageRange.upperBound
|
||
|
||
for highlight in highlights 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(rdHexString: highlight.color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35),
|
||
range: relativeRange
|
||
)
|
||
case .underline:
|
||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
|
||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 将搜索命中高亮以富文本背景色形式应用到页面内容上
|
||
/// - Parameters:
|
||
/// - content: 待修改的富文本
|
||
/// - page: 目标文本页
|
||
/// - searchState: 当前搜索状态
|
||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 构建页面的背景和前景装饰数组(搜索高亮为背景,下划线批注为前景)
|
||
/// - Parameters:
|
||
/// - page: 目标文本页
|
||
/// - highlights: 高亮批注数组
|
||
/// - searchState: 当前搜索状态
|
||
/// - interactionController: 用于计算选区矩形的交互控制器
|
||
/// - Returns: 分离的背景和前景装饰数组
|
||
func buildDecorations(
|
||
page: RDEPUBTextPage,
|
||
highlights: [RDEPUBHighlight],
|
||
searchState: RDEPUBSearchState?,
|
||
interactionController: RDEPUBPageInteractionController
|
||
) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
|
||
var background: [RDEPUBTextOverlayDecoration] = []
|
||
var foreground: [RDEPUBTextOverlayDecoration] = []
|
||
let pageRange = absoluteOffsetRange(for: page)
|
||
let pageStart = pageRange.lowerBound
|
||
let pageEndExclusive = pageRange.upperBound
|
||
|
||
if let searchState {
|
||
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)
|
||
|
||
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 absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||
guard !rects.isEmpty else { continue }
|
||
|
||
let isActive = match == searchState.currentMatch
|
||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||
let color = isActive ? activeColor : normalColor
|
||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||
}
|
||
}
|
||
|
||
for highlight in highlights 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 absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||
guard !rects.isEmpty else { continue }
|
||
|
||
let color = UIColor(rdHexString: highlight.color, alpha: 0.35)
|
||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
|
||
let decoration = RDEPUBTextOverlayDecoration(
|
||
kind: highlight.style == .underline ? .underline : .highlight,
|
||
absoluteRange: absoluteRange,
|
||
rects: rects,
|
||
color: color
|
||
)
|
||
|
||
if decoration.kind == .underline {
|
||
foreground.append(decoration)
|
||
} else {
|
||
background.append(decoration)
|
||
}
|
||
}
|
||
|
||
return (background, foreground)
|
||
}
|
||
|
||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||
let lowerBound = page.pageStartOffset
|
||
let upperBound = page.pageEndOffset + 1
|
||
return lowerBound..<max(upperBound, lowerBound)
|
||
}
|
||
}
|