795 lines
36 KiB
Swift
795 lines
36 KiB
Swift
|
|
import UIKit
|
|||
|
|
|
|||
|
|
/// 核心阅读器视图 (Based on UITextView)
|
|||
|
|
/// 职责:渲染全量富文本、处理原生文本选择、弹出自定义菜单、渲染笔记高亮
|
|||
|
|
/// 架构级优化:直接操作 textStorage,避免重新赋值 attributedText 导致的滚动位置重置
|
|||
|
|
class ArticleRichTextView: UITextView {
|
|||
|
|
|
|||
|
|
// MARK: - Callbacks
|
|||
|
|
/// 划线回调 (返回全局 NSRange)
|
|||
|
|
var onHighlightAction: ((NSRange) -> Void)?
|
|||
|
|
/// 写想法回调 (返回全局 NSRange)
|
|||
|
|
var onThoughtAction: ((NSRange) -> Void)?
|
|||
|
|
/// 笔记点击回调 (返回点击的笔记)
|
|||
|
|
var onNoteTap: ((Note) -> Void)?
|
|||
|
|
/// 滚动进度回调 (返回当前进度 0.0-1.0)
|
|||
|
|
var onScrollProgress: ((Float) -> Void)?
|
|||
|
|
/// 复制成功回调(选中文字点复制后调用,用于展示「已复制」Toast)
|
|||
|
|
var onCopySuccess: (() -> Void)?
|
|||
|
|
|
|||
|
|
// MARK: - Data State
|
|||
|
|
// 缓存当前的笔记 ID 列表,用于快速判断是否需要更新
|
|||
|
|
private var currentNoteIds: Set<String> = []
|
|||
|
|
|
|||
|
|
// ✅ 保留 notes 变量用于点击检测
|
|||
|
|
private var notes: [Note] = []
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:缓存当前内容字符串的 Hash,避免不必要的更新
|
|||
|
|
private var lastContentStringHash: Int = 0
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:保留 TextKit 1 组件的引用,防止被释放
|
|||
|
|
private var textKit1Storage: NSTextStorage?
|
|||
|
|
private var textKit1LayoutManager: NSLayoutManager?
|
|||
|
|
|
|||
|
|
// ✅ 滚动进度更新防抖(加强防抖,避免频繁调用)
|
|||
|
|
private var lastProgressUpdateTime: TimeInterval = 0
|
|||
|
|
private var lastReportedProgress: Float = -1.0 // 记录上次报告的进度
|
|||
|
|
private let progressUpdateInterval: TimeInterval = 0.2 // 200ms 防抖
|
|||
|
|
|
|||
|
|
// MARK: - Init (核心修复:强制锁定 TextKit 1)
|
|||
|
|
|
|||
|
|
/// 创建 TextKit 1 组件的辅助方法
|
|||
|
|
private func createTextKit1Components() -> (NSTextStorage, NSLayoutManager, NSTextContainer) {
|
|||
|
|
let storage = NSTextStorage()
|
|||
|
|
let layoutManager = NSLayoutManager()
|
|||
|
|
let container = NSTextContainer(size: .zero)
|
|||
|
|
|
|||
|
|
// 组装 TextKit 1 堆栈(顺序很重要)
|
|||
|
|
storage.addLayoutManager(layoutManager)
|
|||
|
|
layoutManager.addTextContainer(container)
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:确保 container 已经关联到 layoutManager
|
|||
|
|
// layoutManager.addTextContainer(container) 会自动设置 container.layoutManager
|
|||
|
|
|
|||
|
|
// 配置容器
|
|||
|
|
container.widthTracksTextView = true
|
|||
|
|
container.heightTracksTextView = true
|
|||
|
|
container.lineFragmentPadding = 0 // 移除默认的左右留白
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:保留引用,防止被释放
|
|||
|
|
self.textKit1Storage = storage
|
|||
|
|
self.textKit1LayoutManager = layoutManager
|
|||
|
|
|
|||
|
|
return (storage, layoutManager, container)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 无参初始化器:强制使用 TextKit 1
|
|||
|
|
init() {
|
|||
|
|
// ⚡️ 关键:先创建组件,再调用 super.init
|
|||
|
|
let storage = NSTextStorage()
|
|||
|
|
let layoutManager = NSLayoutManager()
|
|||
|
|
let container = NSTextContainer(size: .zero)
|
|||
|
|
|
|||
|
|
// 组装 TextKit 1 堆栈
|
|||
|
|
storage.addLayoutManager(layoutManager)
|
|||
|
|
layoutManager.addTextContainer(container)
|
|||
|
|
|
|||
|
|
// 配置容器
|
|||
|
|
container.widthTracksTextView = true
|
|||
|
|
container.heightTracksTextView = true
|
|||
|
|
container.lineFragmentPadding = 0
|
|||
|
|
|
|||
|
|
// 调用父类初始化
|
|||
|
|
super.init(frame: .zero, textContainer: container)
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:初始化后保留引用
|
|||
|
|
self.textKit1Storage = storage
|
|||
|
|
self.textKit1LayoutManager = layoutManager
|
|||
|
|
|
|||
|
|
setupConfiguration()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 带参数的初始化器:也强制使用 TextKit 1
|
|||
|
|
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|||
|
|
// ✅ 修正:如果传入了 textContainer,使用它;否则创建新的 TextKit 1 组件
|
|||
|
|
let finalContainer: NSTextContainer
|
|||
|
|
if let providedContainer = textContainer, providedContainer.layoutManager != nil {
|
|||
|
|
// 如果传入的 container 已经有 layoutManager,直接使用
|
|||
|
|
finalContainer = providedContainer
|
|||
|
|
} else {
|
|||
|
|
// 创建新的 TextKit 1 组件
|
|||
|
|
let storage = NSTextStorage()
|
|||
|
|
let layoutManager = NSLayoutManager()
|
|||
|
|
let container = NSTextContainer(size: .zero)
|
|||
|
|
|
|||
|
|
storage.addLayoutManager(layoutManager)
|
|||
|
|
layoutManager.addTextContainer(container)
|
|||
|
|
|
|||
|
|
container.widthTracksTextView = true
|
|||
|
|
container.heightTracksTextView = true
|
|||
|
|
container.lineFragmentPadding = 0
|
|||
|
|
|
|||
|
|
self.textKit1Storage = storage
|
|||
|
|
self.textKit1LayoutManager = layoutManager
|
|||
|
|
|
|||
|
|
finalContainer = container
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
super.init(frame: frame, textContainer: finalContainer)
|
|||
|
|
setupConfiguration()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Storyboard 初始化器:也强制使用 TextKit 1
|
|||
|
|
required init?(coder: NSCoder) {
|
|||
|
|
// ✅ 修正:即使从 Storyboard 加载,也创建 TextKit 1 组件
|
|||
|
|
let storage = NSTextStorage()
|
|||
|
|
let layoutManager = NSLayoutManager()
|
|||
|
|
let container = NSTextContainer(size: .zero)
|
|||
|
|
|
|||
|
|
storage.addLayoutManager(layoutManager)
|
|||
|
|
layoutManager.addTextContainer(container)
|
|||
|
|
|
|||
|
|
container.widthTracksTextView = true
|
|||
|
|
container.heightTracksTextView = true
|
|||
|
|
container.lineFragmentPadding = 0
|
|||
|
|
|
|||
|
|
super.init(frame: .zero, textContainer: container)
|
|||
|
|
|
|||
|
|
self.textKit1Storage = storage
|
|||
|
|
self.textKit1LayoutManager = layoutManager
|
|||
|
|
|
|||
|
|
setupConfiguration()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Configuration
|
|||
|
|
private func setupConfiguration() {
|
|||
|
|
// 1. 基础属性
|
|||
|
|
self.isEditable = false // 只读
|
|||
|
|
self.isSelectable = true // 允许选择
|
|||
|
|
self.isScrollEnabled = true // 允许滚动 (方案B的核心优势)
|
|||
|
|
self.showsVerticalScrollIndicator = false
|
|||
|
|
self.backgroundColor = .clear // 由外部容器控制背景
|
|||
|
|
|
|||
|
|
// ✅ 修复单屏滚动问题:强制启用滚动,即使内容高度小于可见区域
|
|||
|
|
// UITextView 默认情况下,如果内容高度 <= 可见高度,会自动禁用滚动
|
|||
|
|
// 我们需要强制保持滚动启用状态
|
|||
|
|
self.alwaysBounceVertical = true // 允许弹性滚动
|
|||
|
|
self.bounces = true // 启用弹性效果
|
|||
|
|
|
|||
|
|
// 2. 内边距配置 (关键:与设计稿对齐)
|
|||
|
|
// 这里的 Inset 相当于页面左右的 Padding
|
|||
|
|
// ✅ 底部内边距设为 140pt,为外部悬浮按钮留出空间(按钮高度 54pt + 阴影 + 底部间距 20pt + 额外留白)
|
|||
|
|
self.textContainerInset = UIEdgeInsets(top: 0, left: 24, bottom: 140, right: 24)
|
|||
|
|
|
|||
|
|
// 3. 移除左右留白,确保排版精准
|
|||
|
|
self.textContainer.lineFragmentPadding = 0
|
|||
|
|
|
|||
|
|
// 4. 设置代理 (用于处理 iOS 16+ 菜单和滚动监听)
|
|||
|
|
self.delegate = self
|
|||
|
|
|
|||
|
|
// 5. 交互行为优化
|
|||
|
|
// 禁用链接检测等可能干扰阅读的特性,除非有明确需求
|
|||
|
|
self.dataDetectorTypes = []
|
|||
|
|
|
|||
|
|
// 6. 添加点击手势检测笔记
|
|||
|
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
|||
|
|
tapGesture.cancelsTouchesInView = false // ✅ 避免手势冲突
|
|||
|
|
addGestureRecognizer(tapGesture)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - 拦截系统菜单
|
|||
|
|
/// 只允许"复制"按钮,屏蔽其他系统菜单项(如"查询"、"翻译"、"分享"等)
|
|||
|
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|||
|
|
// 只允许 "复制" 存在,其他一律屏蔽
|
|||
|
|
if action == #selector(copy(_:)) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Layout & Scroll Detection
|
|||
|
|
|
|||
|
|
private var hasCheckedSingleScreen = false
|
|||
|
|
|
|||
|
|
override func layoutSubviews() {
|
|||
|
|
super.layoutSubviews()
|
|||
|
|
|
|||
|
|
// ✅ 修复单屏滚动问题:强制保持滚动启用
|
|||
|
|
// 即使内容高度 <= 可见高度,也要保持滚动能力(用于弹性滚动和手势)
|
|||
|
|
if !isScrollEnabled {
|
|||
|
|
isScrollEnabled = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 检测"一屏内容":如果内容高度 <= 可见高度,立即标记为完成
|
|||
|
|
// 只在首次布局完成后检查一次,避免重复触发
|
|||
|
|
if !hasCheckedSingleScreen && bounds.height > 0 && textStorage.length > 0 {
|
|||
|
|
// 使用 layoutManager 计算实际内容高度(更准确)
|
|||
|
|
let usedRect = layoutManager.usedRect(for: textContainer)
|
|||
|
|
let contentHeight = usedRect.height + textContainerInset.top + textContainerInset.bottom
|
|||
|
|
let visibleHeight = bounds.height - contentInset.top - contentInset.bottom
|
|||
|
|
|
|||
|
|
if contentHeight <= visibleHeight {
|
|||
|
|
// 内容可以在一屏内显示完,立即触发 progress = 1.0
|
|||
|
|
hasCheckedSingleScreen = true
|
|||
|
|
DispatchQueue.main.async { [weak self] in
|
|||
|
|
self?.onScrollProgress?(1.0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Public API (核心重构:架构级优化)
|
|||
|
|
|
|||
|
|
/// 更新内容和笔记
|
|||
|
|
/// - Parameters:
|
|||
|
|
/// - content: 由 ContentBlockBuilder 生成的完整富文本
|
|||
|
|
/// - notes: 笔记列表
|
|||
|
|
func update(content: NSAttributedString, notes: [Note]) {
|
|||
|
|
// ✅ 修正:空内容处理
|
|||
|
|
guard content.length > 0 else {
|
|||
|
|
if self.attributedText.length > 0 {
|
|||
|
|
self.attributedText = NSAttributedString()
|
|||
|
|
self.notes = []
|
|||
|
|
self.currentNoteIds = []
|
|||
|
|
self.lastContentStringHash = 0
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⚡️ 关键优化 1:快速检查内容是否真的变化了
|
|||
|
|
let contentStringHash = content.string.hashValue
|
|||
|
|
let notesHash = Set(notes.map { "\($0.id)_\($0.updatedAt.timeIntervalSince1970)" })
|
|||
|
|
|
|||
|
|
// 如果内容和笔记都没变化,直接返回,避免任何操作
|
|||
|
|
if contentStringHash == lastContentStringHash && notesHash == currentNoteIds {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. 处理正文 (Heavy Operation)
|
|||
|
|
// 只有当正文内容发生实质变化时(比如切课),才重置 attributedText
|
|||
|
|
// 这保证了 ScrollView 的偏移量永远不会因为 SwiftUI 的 view update 而重置
|
|||
|
|
let contentChanged = contentStringHash != lastContentStringHash
|
|||
|
|
if contentChanged {
|
|||
|
|
self.attributedText = content
|
|||
|
|
self.lastContentStringHash = contentStringHash
|
|||
|
|
// 正文变了,笔记肯定要重绘,清空缓存
|
|||
|
|
self.currentNoteIds = []
|
|||
|
|
// ✅ 重置"一屏内容"检测标志
|
|||
|
|
self.hasCheckedSingleScreen = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 修正:更新 notes 变量(用于点击检测)
|
|||
|
|
self.notes = notes
|
|||
|
|
|
|||
|
|
// 2. 处理笔记 (Light Operation)
|
|||
|
|
// 检查笔记是否有变化。如果完全一样,直接跳过,0消耗。
|
|||
|
|
if notesHash != currentNoteIds {
|
|||
|
|
// ⚡️ 关键:在主线程同步更新,但确保 textStorage 已准备好
|
|||
|
|
// 如果 textStorage 还没准备好(比如刚设置了 attributedText),等待一帧
|
|||
|
|
if textStorage.length == 0 && contentChanged {
|
|||
|
|
// 内容刚变化,textStorage 可能还没同步,延迟一帧
|
|||
|
|
DispatchQueue.main.async { [weak self] in
|
|||
|
|
guard let self = self, self.textStorage.length > 0 else { return }
|
|||
|
|
let currentNotesHash = Set(self.notes.map { "\($0.id)_\($0.updatedAt.timeIntervalSince1970)" })
|
|||
|
|
if currentNotesHash == notesHash {
|
|||
|
|
self.updateHighlights(notes: self.notes)
|
|||
|
|
self.currentNoteIds = notesHash
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// textStorage 已准备好,直接更新
|
|||
|
|
updateHighlights(notes: notes)
|
|||
|
|
currentNoteIds = notesHash
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Highlight Engine (外科手术式更新)
|
|||
|
|
|
|||
|
|
/// 直接操作 textStorage 更新高亮样式,避免重新赋值 attributedText
|
|||
|
|
private func updateHighlights(notes: [Note]) {
|
|||
|
|
// ✅ 修正:确保 textStorage 存在且可操作
|
|||
|
|
guard textStorage.length > 0 else {
|
|||
|
|
print("⚠️ [ArticleRichTextView] textStorage is empty, length: \(textStorage.length)")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:直接操作 textStorage,不使用 beginEditing/endEditing
|
|||
|
|
// 因为 beginEditing/endEditing 在某些情况下可能导致死锁
|
|||
|
|
// 直接操作更安全,且对于简单的属性更新,性能影响可忽略
|
|||
|
|
|
|||
|
|
// ⚡️ 关键步骤 1:清洗旧样式 (Clean Slate)
|
|||
|
|
// 我们只移除我们自己添加的样式 (背景色、下划线),保留字体、段落间距等 Base 样式
|
|||
|
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
|||
|
|
textStorage.removeAttribute(.backgroundColor, range: fullRange)
|
|||
|
|
textStorage.removeAttribute(.underlineStyle, range: fullRange)
|
|||
|
|
textStorage.removeAttribute(.underlineColor, range: fullRange)
|
|||
|
|
|
|||
|
|
// ⚡️ 关键步骤 2:分离用户笔记和系统笔记,处理重叠
|
|||
|
|
// ✅ 重叠逻辑:保留所有笔记的范围,对于重叠部分在应用样式时按优先级选择
|
|||
|
|
// ✅ 优先级规则(仅适用于重合部分):
|
|||
|
|
// 1. 有用户划线笔记时,重合部分优先显示蓝色背景,不显示想法笔记线段
|
|||
|
|
// 2. 无用户划线笔记时,重合部分用户想法笔记优先于系统想法笔记
|
|||
|
|
// 3. 系统想法笔记使用下划线样式(和用户想法笔记一样),但颜色不同(深灰色)
|
|||
|
|
let userNotes = notes.filter { !$0.isSystemNote }
|
|||
|
|
let systemNotes = notes.filter { $0.isSystemNote }
|
|||
|
|
|
|||
|
|
// 分离用户笔记类型
|
|||
|
|
let userHighlightNotes = userNotes.filter { $0.type == .highlight }
|
|||
|
|
let userThoughtNotes = userNotes.filter { $0.type == .thought }
|
|||
|
|
|
|||
|
|
// 先处理用户划线笔记,按范围分组
|
|||
|
|
var userHighlightRangeToNotes: [NSRange: [Note]] = [:]
|
|||
|
|
for note in userHighlightNotes {
|
|||
|
|
guard let startIndex = note.startIndex, let length = note.length else {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
let noteRange = NSRange(location: startIndex, length: length)
|
|||
|
|
if NSMaxRange(noteRange) > textStorage.length {
|
|||
|
|
print("⚠️ [ArticleRichTextView] User highlight note range out of bounds: \(noteRange)")
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in userHighlightRangeToNotes {
|
|||
|
|
if NSEqualRanges(noteRange, existingRange) {
|
|||
|
|
userHighlightRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
userHighlightRangeToNotes[noteRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理用户想法笔记,排除与用户划线笔记重叠的部分(只排除重叠部分,保留非重叠部分)
|
|||
|
|
var userThoughtRangeToNotes: [NSRange: [Note]] = [:]
|
|||
|
|
for note in userThoughtNotes {
|
|||
|
|
guard let startIndex = note.startIndex, let length = note.length else {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
let noteRange = NSRange(location: startIndex, length: length)
|
|||
|
|
if NSMaxRange(noteRange) > textStorage.length {
|
|||
|
|
print("⚠️ [ArticleRichTextView] User thought note range out of bounds: \(noteRange)")
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 检查是否与用户划线笔记重叠,如果有重叠,分割用户想法笔记的范围
|
|||
|
|
var hasOverlap = false
|
|||
|
|
for (highlightRange, _) in userHighlightRangeToNotes {
|
|||
|
|
let intersection = NSIntersectionRange(noteRange, highlightRange)
|
|||
|
|
if intersection.length > 0 {
|
|||
|
|
hasOverlap = true
|
|||
|
|
|
|||
|
|
// 计算非重叠部分
|
|||
|
|
if noteRange.location < highlightRange.location {
|
|||
|
|
// 用户想法笔记在用户划线笔记之前,保留前面的部分
|
|||
|
|
let beforeRange = NSRange(
|
|||
|
|
location: noteRange.location,
|
|||
|
|
length: highlightRange.location - noteRange.location
|
|||
|
|
)
|
|||
|
|
if beforeRange.length > 0 {
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in userThoughtRangeToNotes {
|
|||
|
|
if NSEqualRanges(beforeRange, existingRange) {
|
|||
|
|
userThoughtRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
userThoughtRangeToNotes[beforeRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if NSMaxRange(noteRange) > NSMaxRange(highlightRange) {
|
|||
|
|
// 用户想法笔记在用户划线笔记之后,保留后面的部分
|
|||
|
|
let afterRange = NSRange(
|
|||
|
|
location: NSMaxRange(highlightRange),
|
|||
|
|
length: NSMaxRange(noteRange) - NSMaxRange(highlightRange)
|
|||
|
|
)
|
|||
|
|
if afterRange.length > 0 {
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in userThoughtRangeToNotes {
|
|||
|
|
if NSEqualRanges(afterRange, existingRange) {
|
|||
|
|
userThoughtRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
userThoughtRangeToNotes[afterRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有重叠,直接添加整个范围
|
|||
|
|
if !hasOverlap {
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in userThoughtRangeToNotes {
|
|||
|
|
if NSEqualRanges(noteRange, existingRange) {
|
|||
|
|
userThoughtRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
userThoughtRangeToNotes[noteRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 合并用户笔记范围(划线笔记在前)
|
|||
|
|
var userRangeToNotes: [NSRange: [Note]] = userHighlightRangeToNotes
|
|||
|
|
for (range, notes) in userThoughtRangeToNotes {
|
|||
|
|
if let existingNotes = userRangeToNotes[range] {
|
|||
|
|
userRangeToNotes[range] = existingNotes + notes
|
|||
|
|
} else {
|
|||
|
|
userRangeToNotes[range] = notes
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理系统笔记,排除与用户笔记重叠的部分(只排除重叠部分,保留非重叠部分)
|
|||
|
|
var systemRangeToNotes: [NSRange: [Note]] = [:]
|
|||
|
|
for note in systemNotes {
|
|||
|
|
guard let startIndex = note.startIndex, let length = note.length else {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
let noteRange = NSRange(location: startIndex, length: length)
|
|||
|
|
if NSMaxRange(noteRange) > textStorage.length {
|
|||
|
|
print("⚠️ [ArticleRichTextView] System note range out of bounds: \(noteRange)")
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 检查是否与用户笔记重叠,如果有重叠,分割系统笔记的范围
|
|||
|
|
var hasOverlap = false
|
|||
|
|
for (userRange, _) in userRangeToNotes {
|
|||
|
|
let intersection = NSIntersectionRange(noteRange, userRange)
|
|||
|
|
if intersection.length > 0 {
|
|||
|
|
hasOverlap = true
|
|||
|
|
|
|||
|
|
// 计算非重叠部分
|
|||
|
|
if noteRange.location < userRange.location {
|
|||
|
|
// 系统笔记在用户笔记之前,保留前面的部分
|
|||
|
|
let beforeRange = NSRange(
|
|||
|
|
location: noteRange.location,
|
|||
|
|
length: userRange.location - noteRange.location
|
|||
|
|
)
|
|||
|
|
if beforeRange.length > 0 {
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in systemRangeToNotes {
|
|||
|
|
if NSEqualRanges(beforeRange, existingRange) {
|
|||
|
|
systemRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
systemRangeToNotes[beforeRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if NSMaxRange(noteRange) > NSMaxRange(userRange) {
|
|||
|
|
// 系统笔记在用户笔记之后,保留后面的部分
|
|||
|
|
let afterRange = NSRange(
|
|||
|
|
location: NSMaxRange(userRange),
|
|||
|
|
length: NSMaxRange(noteRange) - NSMaxRange(userRange)
|
|||
|
|
)
|
|||
|
|
if afterRange.length > 0 {
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in systemRangeToNotes {
|
|||
|
|
if NSEqualRanges(afterRange, existingRange) {
|
|||
|
|
systemRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
systemRangeToNotes[afterRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有重叠,直接添加整个范围
|
|||
|
|
if !hasOverlap {
|
|||
|
|
var found = false
|
|||
|
|
for (existingRange, existingNotes) in systemRangeToNotes {
|
|||
|
|
if NSEqualRanges(noteRange, existingRange) {
|
|||
|
|
systemRangeToNotes[existingRange] = existingNotes + [note]
|
|||
|
|
found = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
systemRangeToNotes[noteRange] = [note]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 合并用户笔记和系统笔记的范围(用户笔记在前)
|
|||
|
|
var rangeToNotes: [NSRange: [Note]] = userRangeToNotes
|
|||
|
|
for (range, notes) in systemRangeToNotes {
|
|||
|
|
if let existingNotes = rangeToNotes[range] {
|
|||
|
|
rangeToNotes[range] = existingNotes + notes
|
|||
|
|
} else {
|
|||
|
|
rangeToNotes[range] = notes
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⚡️ 关键步骤 3:应用样式(按优先级规则,仅适用于重合部分)
|
|||
|
|
// ✅ 优先级规则(仅适用于重合部分):
|
|||
|
|
// 1. 有用户划线笔记时,重合部分优先显示蓝色背景,不显示想法笔记线段
|
|||
|
|
// 2. 无用户划线笔记时,重合部分用户想法笔记优先于系统想法笔记
|
|||
|
|
// 3. 系统想法笔记使用下划线样式(和用户想法笔记一样),但颜色不同(深灰色)
|
|||
|
|
// ✅ 使用 beginEditing/endEditing 确保样式更新生效
|
|||
|
|
textStorage.beginEditing()
|
|||
|
|
defer { textStorage.endEditing() }
|
|||
|
|
|
|||
|
|
// ✅ 系统笔记颜色:深灰色下划线(微信读书风格)
|
|||
|
|
let systemNoteUnderlineColor = UIColor(hex: "666666")
|
|||
|
|
|
|||
|
|
for (range, rangeNotes) in rangeToNotes {
|
|||
|
|
// 分离笔记类型
|
|||
|
|
let userHighlightNotes = rangeNotes.filter { !$0.isSystemNote && $0.type == .highlight }
|
|||
|
|
let userThoughtNotes = rangeNotes.filter { !$0.isSystemNote && $0.type == .thought }
|
|||
|
|
let systemThoughtNotes = rangeNotes.filter { $0.isSystemNote && $0.type == .thought }
|
|||
|
|
let systemHighlightNotes = rangeNotes.filter { $0.isSystemNote && $0.type == .highlight }
|
|||
|
|
|
|||
|
|
var attributes: [NSAttributedString.Key: Any]
|
|||
|
|
|
|||
|
|
// ✅ 优先级1:有用户划线笔记时,重合部分优先显示蓝色背景,不显示想法笔记线段
|
|||
|
|
if !userHighlightNotes.isEmpty {
|
|||
|
|
// 用户划线笔记:蓝色背景,无下划线
|
|||
|
|
attributes = HighlightStyle.highlightAttributes
|
|||
|
|
}
|
|||
|
|
// ✅ 优先级2:无用户划线笔记时,重合部分用户想法笔记优先于系统想法笔记
|
|||
|
|
else if !userThoughtNotes.isEmpty {
|
|||
|
|
// 用户想法笔记:粉色虚线,无背景
|
|||
|
|
attributes = HighlightStyle.thoughtAttributes
|
|||
|
|
}
|
|||
|
|
// ✅ 优先级3:系统想法笔记使用下划线样式(和用户想法笔记一样),但颜色不同
|
|||
|
|
else if !systemThoughtNotes.isEmpty {
|
|||
|
|
// 系统想法笔记:深灰色虚线,无背景(样式和用户想法笔记一样,但颜色不同)
|
|||
|
|
let underlineStyle = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue
|
|||
|
|
attributes = [
|
|||
|
|
.underlineStyle: underlineStyle,
|
|||
|
|
.underlineColor: systemNoteUnderlineColor
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
// ✅ 优先级4:系统划线笔记
|
|||
|
|
else if !systemHighlightNotes.isEmpty {
|
|||
|
|
// 系统划线笔记:深灰色下划线
|
|||
|
|
let underlineStyle = NSUnderlineStyle.single.rawValue
|
|||
|
|
attributes = [
|
|||
|
|
.underlineStyle: underlineStyle,
|
|||
|
|
.underlineColor: systemNoteUnderlineColor
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
// 兜底:不应该到达这里
|
|||
|
|
else {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 确保保留原有的字体和颜色属性,不覆盖原有样式
|
|||
|
|
// 获取原有字体(如果存在),确保不改变字体粗细
|
|||
|
|
if let existingFont = textStorage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont {
|
|||
|
|
attributes[.font] = existingFont
|
|||
|
|
}
|
|||
|
|
// 获取原有文字颜色(如果存在),确保不改变文字颜色
|
|||
|
|
if let existingColor = textStorage.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? UIColor {
|
|||
|
|
attributes[.foregroundColor] = existingColor
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
textStorage.addAttributes(attributes, range: range)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⚡️ 关键:不需要 endEditing,直接操作即可
|
|||
|
|
// textStorage 的变更会自动通知 LayoutManager,但不会触发完整的布局重新计算
|
|||
|
|
// 滚动位置会保持稳定
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Tap Detection
|
|||
|
|
|
|||
|
|
// MARK: - Tap Handling
|
|||
|
|
|
|||
|
|
/// 处理点击手势
|
|||
|
|
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
|||
|
|
guard gesture.state == .ended else { return }
|
|||
|
|
|
|||
|
|
// 1. 极简逻辑:只要有选中态,发生点击就立即清除并返回
|
|||
|
|
// 无论点的是选区内、选区外、还是笔记,先让蓝框消失,界面回归清爽
|
|||
|
|
// 用户需要再次点击才能打开笔记,这样交互更清晰
|
|||
|
|
if selectedRange.length > 0 {
|
|||
|
|
selectedRange = NSRange(location: 0, length: 0)
|
|||
|
|
return // ✅ 修正:清除选中后直接返回,不继续检测笔记
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 坐标计算(只有在没有选中状态时才执行)
|
|||
|
|
let point = gesture.location(in: self)
|
|||
|
|
let adjustedPoint = CGPoint(
|
|||
|
|
x: point.x - textContainerInset.left,
|
|||
|
|
y: point.y - textContainerInset.top
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ⚡️ 直接使用 layoutManager(警告不影响功能,且更可靠)
|
|||
|
|
let glyphIndex = layoutManager.glyphIndex(for: adjustedPoint, in: textContainer)
|
|||
|
|
let glyphRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: textContainer)
|
|||
|
|
|
|||
|
|
// 只有点击在文字范围内才触发笔记检测
|
|||
|
|
guard glyphRect.contains(adjustedPoint) else { return }
|
|||
|
|
|
|||
|
|
let characterIndex = layoutManager.characterIndex(for: adjustedPoint, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
|
|||
|
|
|
|||
|
|
// 确保索引有效
|
|||
|
|
guard characterIndex >= 0, characterIndex < attributedText.length else { return }
|
|||
|
|
|
|||
|
|
// 3. 笔记检测(只有在没有选中状态时才执行)
|
|||
|
|
// 优先查找 thought 类型的笔记(因为它的交互更复杂)
|
|||
|
|
// ✅ 修复:拆分复杂表达式,避免编译器类型检查超时
|
|||
|
|
let targetNote: Note? = {
|
|||
|
|
// 先查找 thought 类型的笔记
|
|||
|
|
for note in notes where note.type == .thought {
|
|||
|
|
guard let startIndex = note.startIndex, let length = note.length else { continue }
|
|||
|
|
if characterIndex >= startIndex && characterIndex < (startIndex + length) {
|
|||
|
|
return note
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 再查找其他类型的笔记
|
|||
|
|
for note in notes {
|
|||
|
|
guard let startIndex = note.startIndex, let length = note.length else { continue }
|
|||
|
|
if characterIndex >= startIndex && characterIndex < (startIndex + length) {
|
|||
|
|
return note
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}()
|
|||
|
|
|
|||
|
|
if let note = targetNote {
|
|||
|
|
// 震动反馈
|
|||
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|||
|
|
// 解决手势冲突:延迟回调,确保本次点击不会被误认为 text selection
|
|||
|
|
DispatchQueue.main.async {
|
|||
|
|
self.onNoteTap?(note)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - UITextViewDelegate (Menu Handling & Scroll Detection)
|
|||
|
|
extension ArticleRichTextView: UITextViewDelegate {
|
|||
|
|
|
|||
|
|
// ✅ 滚动监听:简单判断是否滚动到底部(带防抖)
|
|||
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|||
|
|
guard scrollView.contentSize.height > 0 else { return }
|
|||
|
|
|
|||
|
|
// ✅ 防抖:限制更新频率
|
|||
|
|
let now = Date().timeIntervalSince1970
|
|||
|
|
guard now - lastProgressUpdateTime >= progressUpdateInterval else { return }
|
|||
|
|
lastProgressUpdateTime = now
|
|||
|
|
|
|||
|
|
let scrollOffset = scrollView.contentOffset.y
|
|||
|
|
let scrollViewHeight = scrollView.bounds.height
|
|||
|
|
let contentHeight = scrollView.contentSize.height
|
|||
|
|
let bottomInset = scrollView.contentInset.bottom
|
|||
|
|
|
|||
|
|
// ✅ 简单判断:可见区域底部是否接近内容底部
|
|||
|
|
let visibleBottom = scrollOffset + scrollViewHeight - bottomInset
|
|||
|
|
let distanceFromBottom = contentHeight - visibleBottom
|
|||
|
|
|
|||
|
|
let progress: Float
|
|||
|
|
// 如果距离底部 < 100pt,认为已经滚动到底部(用户能看到最后一行)
|
|||
|
|
if distanceFromBottom < 100 {
|
|||
|
|
progress = 1.0
|
|||
|
|
} else {
|
|||
|
|
// 计算当前进度
|
|||
|
|
progress = min(1.0, max(0.0, Float(visibleBottom / contentHeight)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 只有当进度变化超过 0.05 时才回调(避免频繁更新)
|
|||
|
|
if abs(progress - lastReportedProgress) > 0.05 || progress == 1.0 {
|
|||
|
|
lastReportedProgress = progress
|
|||
|
|
onScrollProgress?(progress)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Scroll Control
|
|||
|
|
/// 滚动到指定字符位置
|
|||
|
|
func scrollToCharacter(at index: Int) {
|
|||
|
|
guard index >= 0, index < textStorage.length else {
|
|||
|
|
print("⚠️ [ArticleRichTextView] scrollToCharacter: 索引越界 index=\(index), length=\(textStorage.length)")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
guard contentSize.height > bounds.height else {
|
|||
|
|
print("⚠️ [ArticleRichTextView] scrollToCharacter: 单屏内容,无需滚动")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 将 NSRange 转换为 UITextRange
|
|||
|
|
guard let startPosition = position(from: beginningOfDocument, offset: index),
|
|||
|
|
let endPosition = position(from: startPosition, offset: 1),
|
|||
|
|
let textRange = textRange(from: startPosition, to: endPosition) else {
|
|||
|
|
print("⚠️ [ArticleRichTextView] scrollToCharacter: 无法创建 UITextRange")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取目标位置的矩形
|
|||
|
|
let rect = firstRect(for: textRange)
|
|||
|
|
|
|||
|
|
// 计算滚动偏移:让目标位置在屏幕中间偏上(30%)
|
|||
|
|
let targetOffset = max(0, rect.origin.y - bounds.height * 0.3)
|
|||
|
|
|
|||
|
|
print("✅ [ArticleRichTextView] scrollToCharacter: index=\(index), rect=\(rect), targetOffset=\(targetOffset)")
|
|||
|
|
|
|||
|
|
setContentOffset(CGPoint(x: 0, y: targetOffset), animated: true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Menu Delegate
|
|||
|
|
/// iOS 16+ 自定义菜单:只显示"划线"、"想法"、"复制"三个按钮
|
|||
|
|
@available(iOS 16.0, *)
|
|||
|
|
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
|||
|
|
|
|||
|
|
// 1. 划线按钮
|
|||
|
|
let highlightAction = UIAction(title: "划线", image: UIImage(systemName: "highlighter")) { [weak self] _ in
|
|||
|
|
self?.handleMenuAction(type: .highlight, range: range)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 想法按钮
|
|||
|
|
let thoughtAction = UIAction(title: "写想法", image: UIImage(systemName: "bubble.left.and.bubble.right")) { [weak self] _ in
|
|||
|
|
self?.handleMenuAction(type: .thought, range: range)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 复制按钮 (手动实现,以便复制后清除选中态)
|
|||
|
|
let copyAction = UIAction(title: "复制", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
|
|||
|
|
guard let self = self else { return }
|
|||
|
|
|
|||
|
|
// 获取选中文本并复制
|
|||
|
|
if let start = self.position(from: self.beginningOfDocument, offset: range.location),
|
|||
|
|
let end = self.position(from: start, offset: range.length),
|
|||
|
|
let textRange = self.textRange(from: start, to: end),
|
|||
|
|
let text = self.text(in: textRange) {
|
|||
|
|
|
|||
|
|
UIPasteboard.general.string = text
|
|||
|
|
|
|||
|
|
// 体验优化:复制完立即清除选中态
|
|||
|
|
self.selectedRange = NSRange(location: 0, length: 0)
|
|||
|
|
|
|||
|
|
// 复制成功后回调,用于展示「已复制」Toast
|
|||
|
|
DispatchQueue.main.async {
|
|||
|
|
self.onCopySuccess?()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 重组菜单:只放这三个按钮,忽略系统推荐的 suggestedActions
|
|||
|
|
return UIMenu(children: [highlightAction, thoughtAction, copyAction])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理菜单点击
|
|||
|
|
private func handleMenuAction(type: NoteType, range: NSRange) {
|
|||
|
|
// ✅ 修正:范围校验
|
|||
|
|
guard range.location >= 0,
|
|||
|
|
range.length > 0,
|
|||
|
|
range.location + range.length <= attributedText.length else {
|
|||
|
|
print("⚠️ [ArticleRichTextView] 无效的选择范围: \(range), 文档长度: \(attributedText.length)")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 立即清除选区,提供更好的视觉反馈
|
|||
|
|
self.selectedRange = NSRange(location: 0, length: 0)
|
|||
|
|
|
|||
|
|
if type == .highlight {
|
|||
|
|
onHighlightAction?(range)
|
|||
|
|
} else {
|
|||
|
|
onThoughtAction?(range)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|