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

795 lines
36 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}