feat: restore auto-complete for compose scene content input
This commit is contained in:
parent
f7d0186bf3
commit
e7ef0f79c7
|
@ -376,7 +376,6 @@
|
|||
DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; };
|
||||
DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
|
||||
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; };
|
||||
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; };
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
|
||||
DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6461426A170AB00B0E31B /* ComposeViewController.swift */; };
|
||||
DBC6461826A170AB00B0E31B /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DBC6461626A170AB00B0E31B /* MainInterface.storyboard */; };
|
||||
|
@ -962,7 +961,6 @@
|
|||
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
|
||||
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
|
||||
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
|
||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
||||
DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DBC6461426A170AB00B0E31B /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -2520,7 +2518,6 @@
|
|||
DBBC24D526A54BCB00398BB9 /* Helper */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
|
||||
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
|
||||
);
|
||||
path = Helper;
|
||||
|
@ -3246,7 +3243,6 @@
|
|||
DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */,
|
||||
DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */,
|
||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */,
|
||||
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
|
||||
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
|
||||
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
|
||||
|
|
|
@ -106,13 +106,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
// let composeToolbarBackgroundView = UIView()
|
||||
//
|
||||
//
|
||||
// private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
|
||||
// let viewController = AutoCompleteViewController()
|
||||
// viewController.viewModel = AutoCompleteViewModel(context: context, authContext: viewModel.authContext)
|
||||
// viewController.delegate = self
|
||||
// viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel
|
||||
// return viewController
|
||||
// }()
|
||||
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
@ -243,33 +237,6 @@ extension ComposeViewController {
|
|||
// // update layout when keyboard show/dismiss
|
||||
// view.layoutIfNeeded()
|
||||
//
|
||||
|
||||
//
|
||||
// // bind auto-complete
|
||||
// viewModel.$autoCompleteInfo
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] info in
|
||||
// guard let self = self else { return }
|
||||
// let textEditorView = self.textEditorView
|
||||
// if self.autoCompleteViewController.view.superview == nil {
|
||||
// self.autoCompleteViewController.view.frame = self.view.bounds
|
||||
// // add to container view. seealso: `viewDidLayoutSubviews()`
|
||||
// self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view)
|
||||
// self.addChild(self.autoCompleteViewController)
|
||||
// self.autoCompleteViewController.didMove(toParent: self)
|
||||
// self.autoCompleteViewController.view.isHidden = true
|
||||
// self.tableView.autoCompleteViewController = self.autoCompleteViewController
|
||||
// }
|
||||
// self.updateAutoCompleteViewControllerLayout()
|
||||
// self.autoCompleteViewController.view.isHidden = info == nil
|
||||
// guard let info = info else { return }
|
||||
// let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
|
||||
// self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY
|
||||
// self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
|
||||
// self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
//
|
||||
// // bind publish bar button state
|
||||
// viewModel.$isPublishBarButtonItemEnabled
|
||||
// .receive(on: DispatchQueue.main)
|
||||
|
@ -431,23 +398,6 @@ extension ComposeViewController {
|
|||
// viewModel.traitCollectionDidChangePublisher.send()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
updateAutoCompleteViewControllerLayout()
|
||||
}
|
||||
|
||||
private func updateAutoCompleteViewControllerLayout() {
|
||||
// pin autoCompleteViewController frame to current view
|
||||
// if let containerView = autoCompleteViewController.view.superview {
|
||||
// let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view)
|
||||
// if viewFrameInWindow.origin.x != 0 {
|
||||
// autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
|
||||
// }
|
||||
// autoCompleteViewController.view.frame.size.width = view.frame.width
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//extension ComposeViewController {
|
||||
|
@ -661,126 +611,11 @@ extension ComposeViewController {
|
|||
// return true
|
||||
// }
|
||||
//
|
||||
// func textViewDidChange(_ textView: UITextView) {
|
||||
// switch textView {
|
||||
// case textEditorView.textView:
|
||||
// // update model
|
||||
// let metaText = self.textEditorView
|
||||
// let backedString = metaText.backedString
|
||||
// viewModel.composeStatusAttribute.composeContent = backedString
|
||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)")
|
||||
|
||||
//
|
||||
// // configure auto completion
|
||||
// setupAutoComplete(for: textView)
|
||||
// default:
|
||||
// assertionFailure()
|
||||
// }
|
||||
// }
|
||||
|
||||
//
|
||||
// struct AutoCompleteInfo {
|
||||
// // model
|
||||
// let inputText: Substring
|
||||
// // range
|
||||
// let symbolRange: Range<String.Index>
|
||||
// let symbolString: Substring
|
||||
// let toCursorRange: Range<String.Index>
|
||||
// let toCursorString: Substring
|
||||
// let toHighlightEndRange: Range<String.Index>
|
||||
// let toHighlightEndString: Substring
|
||||
// // geometry
|
||||
// var textBoundingRect: CGRect = .zero
|
||||
// var symbolBoundingRect: CGRect = .zero
|
||||
// }
|
||||
//
|
||||
// private func setupAutoComplete(for textView: UITextView) {
|
||||
// guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else {
|
||||
// viewModel.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 = viewModel.autoCompleteRetryLayoutTimes
|
||||
// guard textBoundingRect.size != .zero else {
|
||||
// viewModel.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
|
||||
// }
|
||||
// viewModel.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
|
||||
// viewModel.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
|
||||
// }
|
||||
|
||||
//
|
||||
// func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
||||
// switch textView {
|
||||
|
|
|
@ -41,8 +41,6 @@ final class ComposeViewModel: NSObject {
|
|||
//
|
||||
// @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
|
||||
// @Published var repliedToCellFrame: CGRect = .zero
|
||||
// @Published var autoCompleteRetryLayoutTimes = 0
|
||||
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
|
||||
|
||||
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
|
||||
// var isViewAppeared = false
|
||||
|
|
|
@ -88,7 +88,7 @@ extension AutoCompleteViewController {
|
|||
])
|
||||
|
||||
tableView.delegate = self
|
||||
// viewModel.setupDiffableDataSource(tableView: tableView)
|
||||
viewModel.setupDiffableDataSource(tableView: tableView)
|
||||
|
||||
// bind to layout chevron
|
||||
viewModel.symbolBoundingRect
|
||||
|
|
|
@ -6,17 +6,18 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonCore
|
||||
|
||||
extension AutoCompleteViewModel {
|
||||
|
||||
// func setupDiffableDataSource(
|
||||
// tableView: UITableView
|
||||
// ) {
|
||||
// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
|
||||
//
|
||||
// var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
|
||||
// snapshot.appendSections([.main])
|
||||
// diffableDataSource?.apply(snapshot)
|
||||
// }
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView
|
||||
) {
|
||||
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ public final class ComposeContentViewController: UIViewController {
|
|||
public var viewModel: ComposeContentViewModel!
|
||||
private(set) lazy var composeContentToolbarViewModel = ComposeContentToolbarView.ViewModel(delegate: self)
|
||||
|
||||
// tableView container
|
||||
let tableView: ComposeTableView = {
|
||||
let tableView = ComposeTableView()
|
||||
tableView.estimatedRowHeight = UITableView.automaticDimension
|
||||
|
@ -29,6 +30,17 @@ public final class ComposeContentViewController: UIViewController {
|
|||
return tableView
|
||||
}()
|
||||
|
||||
// auto complete
|
||||
private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
|
||||
let viewController = AutoCompleteViewController()
|
||||
viewController.viewModel = AutoCompleteViewModel(context: viewModel.context, authContext: viewModel.authContext)
|
||||
viewController.delegate = self
|
||||
// viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel
|
||||
return viewController
|
||||
}()
|
||||
|
||||
// toolbar
|
||||
|
||||
lazy var composeContentToolbarView = ComposeContentToolbarView(viewModel: composeContentToolbarViewModel)
|
||||
var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
||||
let composeContentToolbarBackgroundView = UIView()
|
||||
|
@ -218,6 +230,32 @@ extension ComposeContentViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind auto-complete
|
||||
viewModel.$autoCompleteInfo
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] info in
|
||||
guard let self = self else { return }
|
||||
guard let textView = self.viewModel.contentMetaText?.textView else { return }
|
||||
if self.autoCompleteViewController.view.superview == nil {
|
||||
self.autoCompleteViewController.view.frame = self.view.bounds
|
||||
// add to container view. seealso: `viewDidLayoutSubviews()`
|
||||
self.viewModel.composeContentTableViewCell.contentView.addSubview(self.autoCompleteViewController.view)
|
||||
self.addChild(self.autoCompleteViewController)
|
||||
self.autoCompleteViewController.didMove(toParent: self)
|
||||
self.autoCompleteViewController.view.isHidden = true
|
||||
self.tableView.autoCompleteViewController = self.autoCompleteViewController
|
||||
}
|
||||
self.updateAutoCompleteViewControllerLayout()
|
||||
self.autoCompleteViewController.view.isHidden = info == nil
|
||||
guard let info = info else { return }
|
||||
let symbolBoundingRectInContainer = textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
|
||||
print(info.symbolBoundingRect)
|
||||
self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY + self.viewModel.contentTextViewFrame.minY
|
||||
self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
|
||||
self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind toolbar
|
||||
bindToolbarViewModel()
|
||||
}
|
||||
|
@ -226,6 +264,7 @@ extension ComposeContentViewController {
|
|||
super.viewDidLayoutSubviews()
|
||||
|
||||
viewModel.viewLayoutFrame.update(view: view)
|
||||
updateAutoCompleteViewControllerLayout()
|
||||
}
|
||||
|
||||
public override func viewSafeAreaInsetsDidChange() {
|
||||
|
@ -264,6 +303,17 @@ extension ComposeContentViewController {
|
|||
viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength)
|
||||
viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength)
|
||||
}
|
||||
|
||||
private func updateAutoCompleteViewControllerLayout() {
|
||||
// pin autoCompleteViewController frame to current view
|
||||
if let containerView = autoCompleteViewController.view.superview {
|
||||
let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view)
|
||||
if viewFrameInWindow.origin.x != 0 {
|
||||
autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
|
||||
}
|
||||
autoCompleteViewController.view.frame.size.width = view.frame.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
@ -427,3 +477,13 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AutoCompleteViewControllerDelegate
|
||||
extension ComposeContentViewController: AutoCompleteViewControllerDelegate {
|
||||
func autoCompleteViewController(
|
||||
_ viewController: AutoCompleteViewController,
|
||||
didSelectItem item: AutoCompleteItem
|
||||
) {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did select item: \(String(describing: item))")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
}
|
|
@ -34,6 +34,10 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||
// author (me)
|
||||
@Published var authContext: AuthContext
|
||||
|
||||
// auto-complete info
|
||||
@Published var autoCompleteRetryLayoutTimes = 0
|
||||
@Published var autoCompleteInfo: AutoCompleteInfo? = nil
|
||||
|
||||
// output
|
||||
|
||||
// limit
|
||||
|
@ -98,9 +102,9 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||
// UI & UX
|
||||
@Published var replyToCellFrame: CGRect = .zero
|
||||
@Published var contentCellFrame: CGRect = .zero
|
||||
@Published var contentTextViewFrame: CGRect = .zero
|
||||
@Published var scrollViewState: ScrollViewState = .fold
|
||||
|
||||
|
||||
public init(
|
||||
context: AppContext,
|
||||
authContext: AuthContext,
|
||||
|
@ -210,6 +214,23 @@ extension ComposeContentViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
extension ComposeContentViewModel {
|
||||
struct AutoCompleteInfo {
|
||||
// model
|
||||
let inputText: Substring
|
||||
// range
|
||||
let symbolRange: Range<String.Index>
|
||||
let symbolString: Substring
|
||||
let toCursorRange: Range<String.Index>
|
||||
let toCursorString: Substring
|
||||
let toHighlightEndRange: Range<String.Index>
|
||||
let toHighlightEndString: Substring
|
||||
// geometry
|
||||
var textBoundingRect: CGRect = .zero
|
||||
var symbolBoundingRect: CGRect = .zero
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeContentViewModel {
|
||||
func createNewPollOptionIfCould() {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
|
@ -286,77 +307,6 @@ extension ComposeContentViewModel {
|
|||
} // end func publisher()
|
||||
}
|
||||
|
||||
// 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:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public func textViewDidEndEditing(_ textView: UITextView) {
|
||||
switch textView {
|
||||
case contentMetaText?.textView:
|
||||
isContentEditing = false
|
||||
case contentWarningMetaText?.textView:
|
||||
isContentWarningEditing = false
|
||||
default:
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DeleteBackwardResponseTextFieldRelayDelegate
|
||||
extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate {
|
||||
|
||||
|
|
|
@ -18,10 +18,12 @@ public struct ComposeContentView: View {
|
|||
static let logger = Logger(subsystem: "ComposeContentView", category: "View")
|
||||
var logger: Logger { ComposeContentView.logger }
|
||||
|
||||
static let contentViewCoordinateSpace = "ComposeContentView.Content"
|
||||
static var margin: CGFloat = 16
|
||||
|
||||
@ObservedObject var viewModel: ComposeContentViewModel
|
||||
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: .zero) {
|
||||
Group {
|
||||
|
@ -106,6 +108,19 @@ public struct ComposeContentView: View {
|
|||
.frame(minHeight: 100)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, ComposeContentView.margin)
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(key: ViewFramePreferenceKey.self, value: proxy.frame(in: .named(ComposeContentView.contentViewCoordinateSpace)))
|
||||
}
|
||||
.onPreferenceChange(ViewFramePreferenceKey.self) { frame in
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content textView frame: \(frame.debugDescription)")
|
||||
let rect = frame.standardized
|
||||
viewModel.contentTextViewFrame = CGRect(
|
||||
origin: frame.origin,
|
||||
size: CGSize(width: floor(rect.width), height: floor(rect.height))
|
||||
)
|
||||
}
|
||||
)
|
||||
// poll
|
||||
pollView
|
||||
.padding(.horizontal, ComposeContentView.margin)
|
||||
|
@ -128,6 +143,7 @@ public struct ComposeContentView: View {
|
|||
)
|
||||
Spacer()
|
||||
} // end VStack
|
||||
.coordinateSpace(name: ComposeContentView.contentViewCoordinateSpace)
|
||||
} // end body
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue