diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift b/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift new file mode 100644 index 000000000000..1c18c01476d7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift @@ -0,0 +1,54 @@ +import Foundation +import GutenbergKit +import Pulse +import Support + +extension GutenbergKit.EditorViewControllerDelegate { + func editor(_ viewController: GutenbergKit.EditorViewController, didLogNetworkRequest request: GutenbergKit.RecordedNetworkRequest) { + guard ExtensiveLogging.enabled, let url = URL(string: request.url) else { + return + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + urlRequest.allHTTPHeaderFields = request.requestHeaders + urlRequest.httpBody = request.requestBody?.data(using: .utf8) + + let httpResponse = HTTPURLResponse( + url: url, + statusCode: request.status, + httpVersion: nil, + headerFields: request.responseHeaders + ) + + LoggerStore.shared.storeRequest( + urlRequest, + response: httpResponse, + error: nil, + data: request.responseBody?.data(using: .utf8) + ) + } +} + +private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) -> String { + switch value { + case .showMore: NSLocalizedString("editor.blockInserter.showMore", value: "Show More", comment: "Button title to expand and show more blocks") + case .showLess: NSLocalizedString("editor.blockInserter.showLess", value: "Show Less", comment: "Button title to collapse and show fewer blocks") + case .search: NSLocalizedString("editor.blockInserter.search", value: "Search", comment: "Placeholder text for block search field") + case .insertBlock: NSLocalizedString("editor.blockInserter.insertBlock", value: "Insert Block", comment: "Context menu action to insert a block") + case .failedToInsertMedia: NSLocalizedString("editor.media.failedToInsert", value: "Failed to insert media", comment: "Error message when media insertion fails") + case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view") + case .noPatternsFound: NSLocalizedString("editor.patterns.noPatternsFound", value: "No Patterns Found", comment: "Title shown when no patterns match the search") + case .insertPattern: NSLocalizedString("editor.patterns.insertPattern", value: "Insert Pattern", comment: "Context menu action to insert a pattern") + case .patternsCategoryUncategorized: NSLocalizedString("editor.patterns.uncategorized", value: "Uncategorized", comment: "Category name for patterns without a category") + case .patternsCategoryAll: NSLocalizedString("editor.patterns.all", value: "All", comment: "Category name for section showing all patterns") + case .loadingEditor: NSLocalizedString("editor.loading.title", value: "Loading Editor", comment: "Text shown while the editor is loading") + case .editorError: NSLocalizedString("editor.error.title", value: "Editor Error", comment: "Title shown when the editor encounters an error") + } +} + +extension EditorLocalizableString { + var localized: String { + getLocalizedString(for: self) + } +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index f6ba67ae4348..d0610e1a9335 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -10,10 +10,8 @@ import WordPressShared import WebKit import CocoaLumberjackSwift import Photos -import Pulse -import Support -class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor { +class NewGutenbergViewController: PostGBKEditorViewController, PostEditor, PublishingEditor { let errorDomain: String = "GutenbergViewController.errorDomain" @@ -56,8 +54,6 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor } } - let navigationBarManager: PostEditorNavigationBarManager - // MARK: - Private variables // TODO: reimplemet @@ -65,21 +61,10 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor // MARK: - GutenbergKit - private var editorViewController: GutenbergKit.EditorViewController - private var isModalDialogOpen = false - lazy var autosaver = Autosaver() { [weak self] in 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) @@ -96,97 +81,40 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor func getHTML() -> String { post.content ?? "" } // MARK: - Initializers - required convenience init( - post: AbstractPost, - replaceEditor: @escaping ReplaceEditorCallback, - editorSession: PostEditorAnalyticsSession? - ) { - self.init( - post: post, - replaceEditor: replaceEditor, - editorSession: editorSession, - // Notice this parameter. - // The value is the default set in the required init but we need to set it explicitly, - // otherwise we'd trigger and infinite loop on this init. - // - // The reason we need this init at all even though the other one does the same job is - // to conform to the PostEditor protocol. - navigationBarManager: nil - ) - } required init( post: AbstractPost, replaceEditor: @escaping ReplaceEditorCallback, - editorSession: PostEditorAnalyticsSession? = nil, - navigationBarManager: PostEditorNavigationBarManager? = nil ) { - self.post = post self.replaceEditor = replaceEditor self.editorSession = PostEditorAnalyticsSession(editor: .gutenbergKit, post: post) - self.navigationBarManager = navigationBarManager ?? PostEditorNavigationBarManager() - - EditorLocalization.localize = getLocalizedString // Create configuration with post content let postType = post is Page ? "page" : "post" let postStatus = post.status?.rawValue ?? "draft" - let editorConfiguration = EditorConfiguration(blog: post.blog, postType: postType) - .toBuilder() - .setTitle(post.postTitle ?? "") - .setContent(post.content ?? "") - .setPostID(post.postID?.intValue) - .setPostStatus(postStatus) - .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) - .build() - - // Use prefetched dependencies if available (fast path with spinner), - // otherwise pass nil and GutenbergKit will fetch them (shows progress bar) - let cachedDependencies = EditorDependencyManager.shared.dependencies(for: post.blog) - - self.editorViewController = GutenbergKit.EditorViewController( - configuration: editorConfiguration, - dependencies: cachedDependencies, - mediaPicker: MediaPickerController(blog: post.blog) - ) - super.init(nibName: nil, bundle: nil) - - self.editorViewController.delegate = self - self.navigationBarManager.delegate = self + super.init( + postId: post.postID?.intValue, + postType: postType, + title: post.postTitle ?? "", + content: post.content ?? "", + status: postStatus, + blog: post.blog + ) } required init?(coder aDecoder: NSCoder) { fatalError() } - deinit { - tearDownKeyboardObservers() - } - // MARK: - Lifecycle methods override func viewDidLoad() { super.viewDidLoad() - setupKeyboardObservers() - - view.backgroundColor = .systemBackground createRevisionOfPost(loadAutosaveRevision: false) - setupEditorView() - configureNavigationBar() - refreshInterface() - - // Load auth cookies if needed (for private sites) - Task { - await loadAuthenticationCookiesAsync() - } - - SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) { - // Do nothing - } // TODO: reimplement // service?.syncJetpackSettingsForBlog(post.blog, success: { [weak self] in @@ -198,41 +126,8 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor onViewDidLoad() } - private func setupEditorView() { - view.tintColor = UIAppColor.editorPrimary - - addChild(editorViewController) - view.addSubview(editorViewController.view) - view.pinSubviewToAllEdges(editorViewController.view) - editorViewController.didMove(toParent: self) - -#if DEBUG - editorViewController.webView.isInspectable = true -#endif - - // Doesn't seem to do anything - setContentScrollView(editorViewController.webView.scrollView) - } - // MARK: - Functions - private func configureNavigationBar() { - navigationController?.navigationBar.accessibilityIdentifier = "Gutenberg Editor Navigation Bar" - navigationItem.leftBarButtonItems = navigationBarManager.leftBarButtonItems - - edgesForExtendedLayout = [] - // TODO: make it work -// configureDefaultNavigationBarAppearance() - - navigationBarManager.moreButton.menu = makeMoreMenu() - navigationBarManager.moreButton.showsMenuAsPrimaryAction = true - } - - private func refreshInterface() { - reloadPublishButton() - navigationItem.rightBarButtonItems = post.status == .trash ? [] : navigationBarManager.rightBarButtonItems - } - func toggleEditingMode() { editorViewController.isCodeEditorEnabled.toggle() } @@ -273,89 +168,16 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor self.present(SubmitFeedbackViewController(source: "gutenberg_kit", feedbackPrefix: "Editor"), animated: true) } - func logException(_ exception: GutenbergJSException, with callback: @escaping () -> Void) { - DispatchQueue.main.async { - WordPressAppDelegate.crashLogging?.logJavaScriptException(exception, callback: callback) - } - } - - // 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 - } - } - - private func loadAuthenticationCookiesAsync() async -> Bool { - guard post.blog.isPrivate() else { - return true - } - - guard let authenticator = RequestAuthenticator(blog: post.blog), - let blogURL = post.blog.url, - let authURL = URL(string: blogURL) else { - return false - } - - let cookieJar = WKWebsiteDataStore.default().httpCookieStore - - return await withCheckedContinuation { continuation in - // Always call authenticator.request() to ensure cookies are properly loaded into WKWebView - authenticator.request(url: authURL, cookieJar: cookieJar) { _ in - DDLogInfo("Authentication cookies loaded into shared cookie store for GutenbergKit") - continuation.resume(returning: true) - } - } - } +/* + Fix issue: Non-'@objc' instance method 'editorDidLoad' declared in 'PostGBKEditorViewController' cannot be overridden from extension - private func setNavigationItemsEnabled(_ enabled: Bool) { - navigationBarManager.closeButton.isEnabled = enabled - navigationBarManager.moreButton.isEnabled = enabled - navigationBarManager.publishButton.isEnabled = enabled - navigationBarManager.undoButton.isEnabled = enabled - navigationBarManager.redoButton.isEnabled = enabled - } + Add the extension back if needed. } extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate { + */ - func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { + override func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { if !editorSession.started { // Note that this method is also used to track startup performance // It assumes this is being called when the editor has finished loading @@ -365,25 +187,16 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate } } - func editor(_ viewContoller: GutenbergKit.EditorViewController, didDisplayInitialContent content: String) { - // Do nothing - } - - func editor(_ viewContoller: GutenbergKit.EditorViewController, didEncounterCriticalError error: any Error) { + override func editor(_ viewContoller: GutenbergKit.EditorViewController, didEncounterCriticalError error: any Error) { onClose?() } - func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateContentWithState state: GutenbergKit.EditorState) { + override func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateContentWithState state: GutenbergKit.EditorState) { editorContentWasUpdated() autosaver.contentDidChange() } - func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateHistoryState state: GutenbergKit.EditorState) { - gutenbergDidRequestToggleRedoButton(!state.hasRedo) - gutenbergDidRequestToggleUndoButton(!state.hasUndo) - } - - func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateFeaturedImage mediaID: Int) { + override func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateFeaturedImage mediaID: Int) { let featuredImageID = post.featuredImage?.mediaID?.intValue guard featuredImageID != mediaID else { @@ -394,15 +207,9 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate self.featuredImageHelper.setFeaturedImage(mediaID: mediaID) } - func editor(_ viewController: GutenbergKit.EditorViewController, didLogException error: GutenbergKit.GutenbergJSException) { - logException(error) { - // Do nothing - } - } - // MARK: - Media Picker Helpers - func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { + override func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { let flags = mediaFilterFlags(using: config.allowedTypes ?? []) let initialSelectionArray: [Int] @@ -434,44 +241,7 @@ 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)") - } - } - - func editor(_ viewController: GutenbergKit.EditorViewController, didOpenModalDialog dialogType: String) { - isModalDialogOpen = true - setNavigationItemsEnabled(false) - } - - func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { - isModalDialogOpen = false - setNavigationItemsEnabled(true) - } - - func editorDidRequestLatestContent(_ controller: GutenbergKit.EditorViewController) -> (title: String, content: String)? { + override func editorDidRequestLatestContent(_ controller: GutenbergKit.EditorViewController) -> (title: String, content: String)? { // Return the current post title and content from Core Data. // This is the authoritative source, updated via autosave. return (post.postTitle ?? "", post.content ?? "") @@ -510,32 +280,62 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate return WPMediaType(rawValue: mediaType) } -} -extension GutenbergKit.EditorViewControllerDelegate { - func editor(_ viewController: GutenbergKit.EditorViewController, didLogNetworkRequest request: GutenbergKit.RecordedNetworkRequest) { - guard ExtensiveLogging.enabled, let url = URL(string: request.url) else { - return + // MARK: - PostEditorNavigationBarManagerDelegate + + override var publishButtonText: String { + return postEditorStateContext.publishButtonText + } + + override var isPublishButtonEnabled: Bool { + return postEditorStateContext.isPublishButtonEnabled + } + + override var uploadingButtonSize: CGSize { + return AztecPostViewController.Constants.uploadingButtonSize + } + + override func navigationBarManager(_ manager: PostEditorNavigationBarManager, closeWasPressed sender: UIButton) { + performAfterUpdatingContent { [self] in + cancelEditing() } + } - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method - urlRequest.allHTTPHeaderFields = request.requestHeaders - urlRequest.httpBody = request.requestBody?.data(using: .utf8) + override func navigationBarManager(_ manager: PostEditorNavigationBarManager, publishButtonWasPressed sender: UIButton) { + performAfterUpdatingContent { [self] in + if editorHasContent { + handlePrimaryActionButtonTap() + } else { + showAlertForEmptyPostPublish() + } + } + } - let httpResponse = HTTPURLResponse( - url: url, - statusCode: request.status, - httpVersion: nil, - headerFields: request.responseHeaders - ) + private func performAfterUpdatingContent(_ closure: @MainActor @escaping () -> Void) { + navigationController?.view.isUserInteractionEnabled = false + Task { @MainActor in + await getLatestContent() + navigationController?.view.isUserInteractionEnabled = true + closure() + } + } - LoggerStore.shared.storeRequest( - urlRequest, - response: httpResponse, - error: nil, - data: request.responseBody?.data(using: .utf8) - ) + override func makeMoreMenu() -> UIMenu { + UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ + UIDeferredMenuElement.uncached { [weak self] callback in + // Common actions at the top so they are always in the same + // relative place. + callback(self?.makeMoreMenuMainSections() ?? []) + }, + UIDeferredMenuElement.uncached { [weak self] callback in + // Dynamic actions at the bottom. The actions are loaded asynchronously + // because they need the latest post content from the editor + // to display the correct state. + self?.performAfterUpdatingContent { + callback(self?.makeMoreMenuAsyncSections() ?? []) + } + } + ]) } } @@ -568,80 +368,6 @@ extension NewGutenbergViewController { } -// 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 extension NewGutenbergViewController/*: GutenbergBridgeDataSource*/ { @@ -729,82 +455,6 @@ extension NewGutenbergViewController: PostEditorStateContextDelegate { } } -// MARK: - PostEditorNavigationBarManagerDelegate - -extension NewGutenbergViewController: PostEditorNavigationBarManagerDelegate { - - var publishButtonText: String { - return postEditorStateContext.publishButtonText - } - - var isPublishButtonEnabled: Bool { - return postEditorStateContext.isPublishButtonEnabled - } - - var uploadingButtonSize: CGSize { - return AztecPostViewController.Constants.uploadingButtonSize - } - - func gutenbergDidRequestToggleUndoButton(_ isDisabled: Bool) { - DispatchQueue.main.async { - UIView.animate(withDuration: 0.2) { - self.navigationBarManager.undoButton.isUserInteractionEnabled = isDisabled ? false : true - self.navigationBarManager.undoButton.alpha = isDisabled ? 0.3 : 1.0 - } - } - } - - func gutenbergDidRequestToggleRedoButton(_ isDisabled: Bool) { - DispatchQueue.main.async { - UIView.animate(withDuration: 0.2) { - self.navigationBarManager.redoButton.isUserInteractionEnabled = isDisabled ? false : true - self.navigationBarManager.redoButton.alpha = isDisabled ? 0.3 : 1.0 - } - } - } - - func navigationBarManager(_ manager: PostEditorNavigationBarManager, closeWasPressed sender: UIButton) { - performAfterUpdatingContent { [self] in - cancelEditing() - } - } - - func navigationBarManager(_ manager: PostEditorNavigationBarManager, undoWasPressed sender: UIButton) { - editorViewController.undo() - } - - func navigationBarManager(_ manager: PostEditorNavigationBarManager, redoWasPressed sender: UIButton) { - editorViewController.redo() - } - - func navigationBarManager(_ manager: PostEditorNavigationBarManager, moreWasPressed sender: UIButton) { - // Currently unsupported, do nothing. - } - - func navigationBarManager(_ manager: PostEditorNavigationBarManager, displayCancelMediaUploads sender: UIButton) { - // Currently unsupported, do nothing. - } - - func navigationBarManager(_ manager: PostEditorNavigationBarManager, publishButtonWasPressed sender: UIButton) { - performAfterUpdatingContent { [self] in - if editorHasContent { - handlePrimaryActionButtonTap() - } else { - showAlertForEmptyPostPublish() - } - } - } - - private func performAfterUpdatingContent(_ closure: @MainActor @escaping () -> Void) { - navigationController?.view.isUserInteractionEnabled = false - Task { @MainActor in - await getLatestContent() - navigationController?.view.isUserInteractionEnabled = true - closure() - } - } -} - /// This extension handles the "more" actions triggered by the top right /// navigation bar button of Gutenberg editor. extension NewGutenbergViewController { @@ -813,24 +463,6 @@ extension NewGutenbergViewController { case managedObjectContextMissing = 2 } - func makeMoreMenu() -> UIMenu { - UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ - UIDeferredMenuElement.uncached { [weak self] callback in - // Common actions at the top so they are always in the same - // relative place. - callback(self?.makeMoreMenuMainSections() ?? []) - }, - UIDeferredMenuElement.uncached { [weak self] callback in - // Dynamic actions at the bottom. The actions are loaded asynchronously - // because they need the latest post content from the editor - // to display the correct state. - self?.performAfterUpdatingContent { - callback(self?.makeMoreMenuAsyncSections() ?? []) - } - } - ]) - } - private func makeMoreMenuMainSections() -> [UIMenuElement] { return [ UIMenu(title: "", subtitle: "", options: .displayInline, children: makeMoreMenuActions()), @@ -965,20 +597,3 @@ private extension NewGutenbergViewController { // Extend Gutenberg JavaScript exception struct to conform the protocol defined in the Crash Logging service extension GutenbergJSException.StacktraceLine: @retroactive AutomatticTracks.JSStacktraceLine {} extension GutenbergJSException: @retroactive AutomatticTracks.JSException {} - -private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) -> String { - switch value { - case .showMore: NSLocalizedString("editor.blockInserter.showMore", value: "Show More", comment: "Button title to expand and show more blocks") - case .showLess: NSLocalizedString("editor.blockInserter.showLess", value: "Show Less", comment: "Button title to collapse and show fewer blocks") - case .search: NSLocalizedString("editor.blockInserter.search", value: "Search", comment: "Placeholder text for block search field") - case .insertBlock: NSLocalizedString("editor.blockInserter.insertBlock", value: "Insert Block", comment: "Context menu action to insert a block") - case .failedToInsertMedia: NSLocalizedString("editor.media.failedToInsert", value: "Failed to insert media", comment: "Error message when media insertion fails") - case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view") - case .noPatternsFound: NSLocalizedString("editor.patterns.noPatternsFound", value: "No Patterns Found", comment: "Title shown when no patterns match the search") - case .insertPattern: NSLocalizedString("editor.patterns.insertPattern", value: "Insert Pattern", comment: "Context menu action to insert a pattern") - case .patternsCategoryUncategorized: NSLocalizedString("editor.patterns.uncategorized", value: "Uncategorized", comment: "Category name for patterns without a category") - case .patternsCategoryAll: NSLocalizedString("editor.patterns.all", value: "All", comment: "Category name for section showing all patterns") - case .loadingEditor: NSLocalizedString("editor.loading.title", value: "Loading Editor", comment: "Text shown while the editor is loading") - case .editorError: NSLocalizedString("editor.error.title", value: "Editor Error", comment: "Title shown when the editor encounters an error") - } -} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift new file mode 100644 index 000000000000..d53c60d0f665 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift @@ -0,0 +1,414 @@ +import Foundation +import UIKit +import WebKit +import GutenbergKit +import WordPressShared +import WordPressUI + +class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewControllerDelegate, PostEditorNavigationBarManagerDelegate { + + let blog: Blog + let navigationBarManager: PostEditorNavigationBarManager + + /* private */ let editorViewController: GutenbergKit.EditorViewController + private let status: String // TODO: Can be deleted? + + private var isModalDialogOpen = false + + private var keyboardShowObserver: Any? + private var keyboardHideObserver: Any? + private var keyboardFrame = CGRect.zero + + private var suggestionViewBottomConstraint: NSLayoutConstraint? + private var currentSuggestionsController: GutenbergSuggestionsViewController? + + init( + postId: Int?, + postType: String, + title: String?, + content: String?, + status: String?, + blog: Blog + ) { + self.status = status ?? "draft" + self.blog = blog + self.navigationBarManager = PostEditorNavigationBarManager() + + EditorLocalization.localize = { $0.localized } + + // Create configuration with post content + let editorConfiguration = EditorConfiguration(blog: blog, postType: postType) + .toBuilder() + .setTitle(title ?? "") + .setContent(content ?? "") + .setPostID(postId) + .setPostStatus(self.status) + .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) + .build() + + // Use prefetched dependencies if available (fast path with spinner), + // otherwise pass nil and GutenbergKit will fetch them (shows progress bar) + let cachedDependencies = EditorDependencyManager.shared.dependencies(for: blog) + + self.editorViewController = GutenbergKit.EditorViewController( + configuration: editorConfiguration, + dependencies: cachedDependencies, + mediaPicker: MediaPickerController(blog: blog) + ) + + super.init(nibName: nil, bundle: nil) + + self.editorViewController.delegate = self + self.navigationBarManager.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + deinit { + tearDownKeyboardObservers() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + setupKeyboardObservers() + setupEditorView() + configureNavigationBar() + refreshInterface() + + // Load auth cookies if needed (for private sites) + Task { + await loadAuthenticationCookiesAsync() + } + + SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: blog) { + // Do nothing + } + } + + func refreshInterface() { + navigationBarManager.reloadPublishButton() + navigationItem.rightBarButtonItems = self.status == "trash" ? [] : navigationBarManager.rightBarButtonItems + } + + func makeMoreMenu() -> UIMenu { + fatalError("To be implemented by subclasses") + } + + // MARK: - GutenbergKit.EditorViewControllerDelegate + + func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { + // Do nothing + } + + func editor(_ viewContoller: GutenbergKit.EditorViewController, didDisplayInitialContent content: String) { + // Do nothing + } + + func editor(_ viewContoller: GutenbergKit.EditorViewController, didEncounterCriticalError error: any Error) { + // Do nothing + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateContentWithState state: GutenbergKit.EditorState) { + // Do nothing + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateHistoryState state: GutenbergKit.EditorState) { + gutenbergDidRequestToggleRedoButton(!state.hasRedo) + gutenbergDidRequestToggleUndoButton(!state.hasUndo) + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateFeaturedImage mediaID: Int) { + // Do nothing + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didLogException exception: GutenbergKit.GutenbergJSException) { + DispatchQueue.main.async { + WordPressAppDelegate.crashLogging?.logJavaScriptException(exception) { + // Do nothing + } + } + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { + // Do nothing + } + + 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)") + } + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didOpenModalDialog dialogType: String) { + isModalDialogOpen = true + setNavigationItemsEnabled(false) + } + + func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { + isModalDialogOpen = false + setNavigationItemsEnabled(true) + } + + func editorDidRequestLatestContent(_ controller: GutenbergKit.EditorViewController) -> (title: String, content: String)? { + // Do nothing + return nil + } + + // MARK: - PostEditorNavigationBarManagerDelegate + + var publishButtonText: String { + wpAssertionFailure("To be implemented by subclasses") + return "" + } + + var isPublishButtonEnabled: Bool { + wpAssertionFailure("To be implemented by subclasses") + return false + } + + var uploadingButtonSize: CGSize { + wpAssertionFailure("To be implemented by subclasses") + return .zero + } + + func navigationBarManager(_ manager: PostEditorNavigationBarManager, closeWasPressed sender: UIButton) { + // Do nothing + } + + func navigationBarManager(_ manager: PostEditorNavigationBarManager, undoWasPressed sender: UIButton) { + editorViewController.undo() + } + + func navigationBarManager(_ manager: PostEditorNavigationBarManager, redoWasPressed sender: UIButton) { + editorViewController.redo() + } + + func navigationBarManager(_ manager: PostEditorNavigationBarManager, moreWasPressed sender: UIButton) { + // Do nothing + } + + func navigationBarManager(_ manager: PostEditorNavigationBarManager, publishButtonWasPressed sender: UIButton) { + // Do nothing + } + + func navigationBarManager(_ manager: PostEditorNavigationBarManager, displayCancelMediaUploads sender: UIButton) { + // Do nothing + } +} + +private extension PostGBKEditorViewController { + func gutenbergDidRequestToggleRedoButton(_ isDisabled: Bool) { + DispatchQueue.main.async { + UIView.animate(withDuration: 0.2) { + self.navigationBarManager.redoButton.isUserInteractionEnabled = isDisabled ? false : true + self.navigationBarManager.redoButton.alpha = isDisabled ? 0.3 : 1.0 + } + } + } + + func gutenbergDidRequestToggleUndoButton(_ isDisabled: Bool) { + DispatchQueue.main.async { + UIView.animate(withDuration: 0.2) { + self.navigationBarManager.undoButton.isUserInteractionEnabled = isDisabled ? false : true + self.navigationBarManager.undoButton.alpha = isDisabled ? 0.3 : 1.0 + } + } + } + + func setNavigationItemsEnabled(_ enabled: Bool) { + navigationBarManager.closeButton.isEnabled = enabled + navigationBarManager.moreButton.isEnabled = enabled + navigationBarManager.publishButton.isEnabled = enabled + navigationBarManager.undoButton.isEnabled = enabled + navigationBarManager.redoButton.isEnabled = enabled + } + + func setupEditorView() { + view.tintColor = UIAppColor.editorPrimary + + addChild(editorViewController) + view.addSubview(editorViewController.view) + view.pinSubviewToAllEdges(editorViewController.view) + editorViewController.didMove(toParent: self) + +#if DEBUG + editorViewController.webView.isInspectable = true +#endif + + // Doesn't seem to do anything + setContentScrollView(editorViewController.webView.scrollView) + } + + func configureNavigationBar() { + navigationController?.navigationBar.accessibilityIdentifier = "Gutenberg Editor Navigation Bar" + navigationItem.leftBarButtonItems = navigationBarManager.leftBarButtonItems + + edgesForExtendedLayout = [] + // TODO: make it work +// configureDefaultNavigationBarAppearance() + + navigationBarManager.moreButton.menu = makeMoreMenu() + navigationBarManager.moreButton.showsMenuAsPrimaryAction = true + } + + func loadAuthenticationCookiesAsync() async -> Bool { + guard blog.isPrivate() else { + return true + } + + guard let authenticator = RequestAuthenticator(blog: blog), + let blogURL = blog.url, + let authURL = URL(string: blogURL) else { + return false + } + + let cookieJar = WKWebsiteDataStore.default().httpCookieStore + + return await withCheckedContinuation { continuation in + // Always call authenticator.request() to ensure cookies are properly loaded into WKWebView + authenticator.request(url: authURL, cookieJar: cookieJar) { _ in + DDLogInfo("Authentication cookies loaded into shared cookie store for GutenbergKit") + continuation.resume(returning: true) + } + } + } + + // MARK: - Keyboard Observers + + 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) + } + } + } + + func tearDownKeyboardObservers() { + if let keyboardShowObserver { + NotificationCenter.default.removeObserver(keyboardShowObserver) + } + if let keyboardHideObserver { + NotificationCenter.default.removeObserver(keyboardHideObserver) + } + } + + 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: - Suggestions implementation + + 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 = blog.dotComID else { + callback(.failure(GutenbergSuggestionsViewController.SuggestionError.notAvailable as NSError)) + return + } + + switch type { + case .mention: + guard SuggestionService.shared.shouldShowSuggestions(for: blog) else { return } + case .xpost: + guard SiteSuggestionService.shared.shouldShowSuggestions(for: 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) + } +}