001project_wildgrowth/ios/WildGrowth/WildGrowth/ArticleRichTextView.swift

795 lines
36 KiB
Swift
Raw Normal View History

2026-02-11 15:26:03 +08:00
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)
}
}
}