diff --git a/Modules/Package.resolved b/Modules/Package.resolved index c5c459ee1f70..6e0b80554082 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "94d091cc524b2f58bb430f21396f16dc040d3bcd7183a98eb196356139a4de86", + "originHash" : "1c49d626b4b59d58f254cb80d59693d56bdbbcf7cc9bf672790a764d6cfb897a", "pins" : [ { "identity" : "alamofire", @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "780efc3905275804aa52127aa532c3fc12d81a68", - "version" : "0.7.0" + "revision" : "9de6625fa92ebf83af0d1a1603ec3426a79a3ddf", + "version" : "0.8.0-alpha.0" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 5b69b85dc041..24187b2c9cc5 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -55,7 +55,7 @@ let package = Package( .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250715"), - .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.7.0"), + .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.8.0-alpha.0"), .package( url: "https://github.com/Automattic/color-studio", revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de" diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift index 53e7e4df9f4a..19978578bd2a 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift @@ -91,4 +91,8 @@ extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControlle func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: GutenbergKit.OpenMediaLibraryAction) { // Do nothing } + + func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) { + // Do nothing + } } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 2001677e45b1..5d861bc70ee5 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -7,6 +7,7 @@ import SafariServices import WordPressData import WordPressShared import WebKit +import CocoaLumberjackSwift class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor { let errorDomain: String = "GutenbergViewController.errorDomain" @@ -79,6 +80,14 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor self.performAutoSave() } + // MARK: - Private Properties + + private var keyboardShowObserver: Any? + private var keyboardHideObserver: Any? + private var keyboardFrame = CGRect.zero + private var suggestionViewBottomConstraint: NSLayoutConstraint? + private var currentSuggestionsController: GutenbergSuggestionsViewController? + // TODO: remove (none of these APIs are needed for the new editor) func prepopulateMediaItems(_ media: [Media]) {} var debouncer = WordPressShared.Debouncer(delay: 10) @@ -146,10 +155,15 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor fatalError() } + deinit { + tearDownKeyboardObservers() + } + // MARK: - Lifecycle methods override func viewDidLoad() { super.viewDidLoad() + setupKeyboardObservers() view.backgroundColor = .systemBackground @@ -218,10 +232,9 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor setTitle(post.postTitle ?? "") editorViewController.setContent(content) - // TODO: reimplement -// SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) { [weak self] in -// self?.gutenberg.updateCapabilities() -// } + SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) { + // Do nothing + } } private func refreshInterface() { @@ -245,7 +258,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor let startTime = CFAbsoluteTimeGetCurrent() let editorData = try? await editorViewController.getTitleAndContent() let duration = CFAbsoluteTimeGetCurrent() - startTime - print("gutenbergkit-measure_get-latest-content:", duration) + DDLogDebug("gutenbergkit-measure_get-latest-content: \(duration)") if let title = editorData?.title, let content = editorData?.content, @@ -273,6 +286,49 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } } + // MARK: - Keyboard Observers + + private func setupKeyboardObservers() { + keyboardShowObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { [weak self] (notification) in + if let self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + self.keyboardFrame = keyboardRect + self.updateConstraintsToAvoidKeyboard(frame: keyboardRect) + } + } + keyboardHideObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { [weak self] (notification) in + if let self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + self.keyboardFrame = keyboardRect + self.updateConstraintsToAvoidKeyboard(frame: keyboardRect) + } + } + } + + private func tearDownKeyboardObservers() { + if let keyboardShowObserver { + NotificationCenter.default.removeObserver(keyboardShowObserver) + } + if let keyboardHideObserver { + NotificationCenter.default.removeObserver(keyboardHideObserver) + } + } + + private func updateConstraintsToAvoidKeyboard(frame: CGRect) { + keyboardFrame = frame + let minimumKeyboardHeight = CGFloat(50) + guard let suggestionViewBottomConstraint else { + return + } + + // There are cases where the keyboard is not visible, but the system instead of returning zero, returns a low number, for example: 0, 3, 69. + // So in those scenarios, we just need to take in account the safe area and ignore the keyboard all together. + if keyboardFrame.height < minimumKeyboardHeight { + suggestionViewBottomConstraint.constant = -self.view.safeAreaInsets.bottom + } + else { + suggestionViewBottomConstraint.constant = -self.keyboardFrame.height + } + } + // MARK: - Activity Indicator private func showActivityIndicator() { @@ -459,6 +515,33 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate } } + func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) { + switch type { + case "at-symbol": + showSuggestions(type: .mention) { [weak self] result in + switch result { + case .success(let suggestion): + // Appended space completes the autocomplete session + self?.editorViewController.appendTextAtCursor(suggestion + " ") + case .failure(let error): + DDLogError("Mention selection cancelled or failed: \(error)") + } + } + case "plus-symbol": + showSuggestions(type: .xpost) { [weak self] result in + switch result { + case .success(let suggestion): + // Appended space completes the autocomplete session + self?.editorViewController.appendTextAtCursor(suggestion + " ") + case .failure(let error): + DDLogError("Xpost selection cancelled or failed: \(error)") + } + } + default: + DDLogError("Unknown autocompleter type: \(type)") + } + } + private func convertMediaInfoArrayToJSONString(_ mediaInfoArray: [MediaInfo]) -> String? { do { let jsonData = try JSONEncoder().encode(mediaInfoArray) @@ -466,7 +549,7 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate return jsonString } } catch { - print("Error encoding MediaInfo array: \(error)") + DDLogError("Error encoding MediaInfo array: \(error)") } return nil } @@ -521,18 +604,80 @@ extension NewGutenbergViewController { present(lightboxVC, animated: true) } - // TODO: reimplement -// func gutenbergDidRequestMention(callback: @escaping (Swift.Result) -> Void) { -// DispatchQueue.main.async(execute: { [weak self] in -// self?.showSuggestions(type: .mention, callback: callback) -// }) -// } -// -// func gutenbergDidRequestXpost(callback: @escaping (Swift.Result) -> Void) { -// DispatchQueue.main.async(execute: { [weak self] in -// self?.showSuggestions(type: .xpost, callback: callback) -// }) -// } +} + +// MARK: - Suggestions implementation + +extension NewGutenbergViewController { + + private func showSuggestions(type: SuggestionType, callback: @escaping (Swift.Result) -> Void) { + // Prevent multiple suggestions UI instances - simply ignore if already showing + guard currentSuggestionsController == nil else { + return + } + guard let siteID = post.blog.dotComID else { + callback(.failure(GutenbergSuggestionsViewController.SuggestionError.notAvailable as NSError)) + return + } + + switch type { + case .mention: + guard SuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return } + case .xpost: + guard SiteSuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return } + } + + let previousFirstResponder = view.findFirstResponder() + let suggestionsController = GutenbergSuggestionsViewController(siteID: siteID, suggestionType: type) + currentSuggestionsController = suggestionsController + suggestionsController.onCompletion = { [weak self] (result) in + callback(result) + + if let self { + // Clear the current controller reference + self.currentSuggestionsController = nil + self.suggestionViewBottomConstraint = nil + + // Clean up the UI (should only happen if parent still exists) + suggestionsController.view.removeFromSuperview() + suggestionsController.removeFromParent() + + previousFirstResponder?.becomeFirstResponder() + } + + var analyticsName: String + switch type { + case .mention: + analyticsName = "user" + case .xpost: + analyticsName = "xpost" + } + + var didSelectSuggestion = false + if case let .success(text) = result, !text.isEmpty { + didSelectSuggestion = true + } + + let analyticsProperties: [String: Any] = [ + "suggestion_type": analyticsName, + "did_select_suggestion": didSelectSuggestion + ] + + WPAnalytics.track(.gutenbergSuggestionSessionFinished, properties: analyticsProperties) + } + addChild(suggestionsController) + view.addSubview(suggestionsController.view) + let suggestionsBottomConstraint = suggestionsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0) + NSLayoutConstraint.activate([ + suggestionsController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0), + suggestionsController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0), + suggestionsBottomConstraint, + suggestionsController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) + ]) + self.suggestionViewBottomConstraint = suggestionsBottomConstraint + updateConstraintsToAvoidKeyboard(frame: keyboardFrame) + suggestionsController.didMove(toParent: self) + } } // MARK: - GutenbergBridgeDataSource