From e7ef0f79c7020fc7cfbe7f99711a3c18e15d3d61 Mon Sep 17 00:00:00 2001 From: CMK Date: Sun, 13 Nov 2022 16:04:29 +0800 Subject: [PATCH] feat: restore auto-complete for compose scene content input --- Mastodon.xcodeproj/project.pbxproj | 4 - .../Scene/Compose/ComposeViewController.swift | 173 +-------------- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 - .../AutoCompleteViewController.swift | 2 +- .../AutoCompleteViewModel+Diffable.swift | 19 +- .../ComposeContentViewController.swift | 60 ++++++ ...eContentViewModel+UITextViewDelegate.swift | 203 ++++++++++++++++++ .../ComposeContentViewModel.swift | 96 ++------- .../Helper/MastodonRegex.swift | 0 .../View/ComposeContentView.swift | 16 ++ 10 files changed, 317 insertions(+), 258 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift rename {Mastodon => MastodonSDK/Sources/MastodonUI/Scene/ComposeContent}/Helper/MastodonRegex.swift (100%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 380f21eac..c6dc27ac1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; - DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; 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 = ""; }; @@ -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 */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index bf9145d6c..a2830edff 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -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 -// let symbolString: Substring -// let toCursorRange: Range -// let toCursorString: Substring -// let toHighlightEndRange: Range -// 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..= cursorIndex else { return nil } -// let symbolRange = highlightStartIndex.. Bool { // switch textView { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 45c9f1e93..df9f7b710 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -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()) // use CurrentValueSubject to make initial event emit // var isViewAppeared = false diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift index aa21057d1..9af1ce9bf 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift @@ -88,7 +88,7 @@ extension AutoCompleteViewController { ]) tableView.delegate = self -// viewModel.setupDiffableDataSource(tableView: tableView) + viewModel.setupDiffableDataSource(tableView: tableView) // bind to layout chevron viewModel.symbolBoundingRect diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift index adbf6ac09..2dd815d0a 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift @@ -6,17 +6,18 @@ // import UIKit +import MastodonCore extension AutoCompleteViewModel { -// func setupDiffableDataSource( -// tableView: UITableView -// ) { -// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView) -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// diffableDataSource?.apply(snapshot) -// } + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 7dde9c8c0..df7246fa7 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -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))") + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift new file mode 100644 index 000000000..5b5c018e2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift @@ -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..= cursorIndex else { return nil } + let symbolRange = highlightStartIndex..) case reply(status: ManagedObjectRecord) } - + public enum ScrollViewState { case fold // snap to input case expand // snap to reply } } +extension ComposeContentViewModel { + struct AutoCompleteInfo { + // model + let inputText: Substring + // range + let symbolRange: Range + let symbolString: Substring + let toCursorRange: Range + let toCursorString: Substring + let toHighlightEndRange: Range + 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 { diff --git a/Mastodon/Helper/MastodonRegex.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift similarity index 100% rename from Mastodon/Helper/MastodonRegex.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift index e1954af04..6dad1e19b 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift @@ -18,9 +18,11 @@ 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) { @@ -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 }