mastodon-ios/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UIT...

204 lines
7.7 KiB
Swift

//
// ComposeContentViewModel+UITextViewDelegate.swift
//
//
// Created by MainasuK on 2022/11/13.
//
import os.log
import UIKit
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {
// Note:
// Xcode warning:
// Publishing changes from within view updates is not allowed, this will cause undefined behavior.
//
// Just ignore the warning and see what will happen
switch textView {
case contentMetaText?.textView:
isContentEditing = true
case contentWarningMetaText?.textView:
isContentWarningEditing = true
default:
assertionFailure()
break
}
}
public func textViewDidChange(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
// update model
guard let metaText = self.contentMetaText else {
assertionFailure()
return
}
let backedString = metaText.backedString
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)")
// configure auto completion
setupAutoComplete(for: textView)
case contentWarningMetaText?.textView:
break
default:
assertionFailure()
}
}
public func textViewDidEndEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = false
case contentWarningMetaText?.textView:
isContentWarningEditing = false
default:
assertionFailure()
break
}
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
switch textView {
case contentMetaText?.textView:
return true
case contentWarningMetaText?.textView:
let isReturn = text == "\n"
if isReturn {
setContentTextViewFirstResponderIfNeeds()
}
return !isReturn
default:
assertionFailure()
return true
}
}
}
extension ComposeContentViewModel {
func insertContentText(text: String) {
guard let contentMetaText = self.contentMetaText else { return }
// FIXME: smart prefix and suffix
let string = contentMetaText.textStorage.string
let isEmpty = string.isEmpty
let hasPrefix = string.hasPrefix(" ")
if hasPrefix || isEmpty {
contentMetaText.textView.insertText(text)
} else {
contentMetaText.textView.insertText(" " + text)
}
}
func setContentTextViewFirstResponderIfNeeds() {
guard let contentMetaText = self.contentMetaText else { return }
guard !contentMetaText.textView.isFirstResponder else { return }
contentMetaText.textView.becomeFirstResponder()
}
func setContentWarningTextViewFirstResponderIfNeeds() {
guard let contentWarningMetaText = self.contentWarningMetaText else { return }
guard !contentWarningMetaText.textView.isFirstResponder else { return }
contentWarningMetaText.textView.becomeFirstResponder()
}
}
extension ComposeContentViewModel {
private func setupAutoComplete(for textView: UITextView) {
guard var autoCompletion = ComposeContentViewModel.scanAutoCompleteInfo(textView: textView) else {
self.autoCompleteInfo = nil
return
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
// get layout text bounding rect
var glyphRange = NSRange()
textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange)
let textContainer = textView.layoutManager.textContainers[0]
let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
let retryLayoutTimes = autoCompleteRetryLayoutTimes
guard textBoundingRect.size != .zero else {
autoCompleteRetryLayoutTimes += 1
// avoid infinite loop
guard retryLayoutTimes < 3 else { return }
// needs retry calculate layout when the rect position changing
DispatchQueue.main.async {
self.setupAutoComplete(for: textView)
}
return
}
autoCompleteRetryLayoutTimes = 0
// get symbol bounding rect
textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange)
let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// set bounding rect and trigger layout
autoCompletion.textBoundingRect = textBoundingRect
autoCompletion.symbolBoundingRect = symbolBoundingRect
autoCompleteInfo = autoCompletion
}
private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? {
guard let text = textView.text,
textView.selectedRange.location > 0, !text.isEmpty,
let selectedRange = Range(textView.selectedRange, in: text) else {
return nil
}
let cursorIndex = selectedRange.upperBound
let _highlightStartIndex: String.Index? = {
var index = text.index(before: cursorIndex)
while index > text.startIndex {
let char = text[index]
if char == "@" || char == "#" || char == ":" {
return index
}
index = text.index(before: index)
}
assert(index == text.startIndex)
let char = text[index]
if char == "@" || char == "#" || char == ":" {
return index
} else {
return nil
}
}()
guard let highlightStartIndex = _highlightStartIndex else { return nil }
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
let matchStartIndex = matchRange.lowerBound
let matchEndIndex = matchRange.upperBound
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
let symbolString = text[symbolRange]
let toCursorRange = highlightStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
let inputText = toHighlightEndString
let autoCompleteInfo = AutoCompleteInfo(
inputText: inputText,
symbolRange: symbolRange,
symbolString: symbolString,
toCursorRange: toCursorRange,
toCursorString: toCursorString,
toHighlightEndRange: toHighlightEndRange,
toHighlightEndString: toHighlightEndString
)
return autoCompleteInfo
}
}