diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 22168f5b2..36042b2d5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -932,6 +932,28 @@ class GutenbergView : FrameLayout { fun undo() { evaluateIfLoaded("editor.undo();") } + /** + * Temporary Android native Find decision spike. + * This is not the final Find in Post API. + */ + fun debugFind(query: String) { + if (!isEditorLoaded) { + Log.e("GutenbergView", "Can't search until the editor has loaded") + return + } + + handler.post { + webView.setFindListener { activeMatchOrdinal, numberOfMatches, isDoneCounting -> + Log.d( + "GutenbergFindSpike", + "query=$query activeMatchOrdinal=$activeMatchOrdinal " + + "numberOfMatches=$numberOfMatches isDoneCounting=$isDoneCounting" + ) + } + + webView.findAllAsync(query) + } + } fun redo() { evaluateIfLoaded("editor.redo();") diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 35bff9043..0bb26ad5c 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -105,6 +105,12 @@ struct EditorView: View { private var moreMenu: some View { Menu { + Button(action: { + viewModel.perform(.find) + }, label: { + Label("Find in Post", systemImage: "magnifyingglass") + }) + Button(action: { viewModel.isCodeEditorEnabled.toggle() }, label: { @@ -163,6 +169,7 @@ private struct _EditorView: UIViewControllerRepresentable { switch $0 { case .redo: viewController?.redo() case .undo: viewController?.undo() + case .find: viewController?.presentFindNavigator() case .triggerCrash: viewController?.webView.evaluateJavaScript(triggerEditorCrashScript, completionHandler: nil) } @@ -402,6 +409,7 @@ private final class EditorViewModel { enum Action { case undo case redo + case find case triggerCrash } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dc7795024..efeae7fb5 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -262,6 +262,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro config.applicationNameForUserAgent = "GutenbergKit/\(GutenbergKitVersion.version)" self.webView = GBWebView(frame: .zero, configuration: config) + self.webView.isFindInteractionEnabled = true self.webView.scrollView.keyboardDismissMode = .interactive self.isWarmupMode = isWarmupMode @@ -299,8 +300,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - webView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor) + webViewBottomToSafeArea ]) + observeKeyboardVisibility() // WebView starts hidden and fades in when editor navigation completes webView.alpha = 0 @@ -340,6 +342,107 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro setupNavigationOverlay() } + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // The find navigator can be dismissed without any accompanying keyboard + // notification, so re-evaluate on layout as well. + updateWebViewBottomConstraint() + } + + // MARK: - Web View Bottom Inset + + /// Pins the web view above the software keyboard. + private lazy var webViewBottomToKeyboard = webView.bottomAnchor.constraint( + equalTo: view.keyboardLayoutGuide.topAnchor + ) + + /// Pins the web view to the bottom of the view, used whenever nothing is + /// docked above it. + /// + /// `UIKeyboardLayoutGuide` folds the system find navigator into the region + /// it tracks. When the navigator and the keyboard are dismissed together, + /// the guide never receives a final update and stays short by the + /// navigator's height, stranding the web view above the bottom of the + /// screen. Swapping to the safe area whenever neither the keyboard nor the + /// navigator is present sidesteps the stale value without changing how the + /// keyboard itself is tracked. + private lazy var webViewBottomToSafeArea = webView.bottomAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.bottomAnchor + ) + + private var isKeyboardVisible = false + + private func observeKeyboardVisibility() { + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(keyboardDidHide), + name: UIResponder.keyboardDidHideNotification, + object: nil + ) + } + + @objc private func keyboardWillShow() { + isKeyboardVisible = true + updateWebViewBottomConstraint() + } + + @objc private func keyboardWillHide(_ notification: Notification) { + isKeyboardVisible = false + updateWebViewBottomConstraint() + + // The find navigator still reports itself visible at this point, so the + // pass above cannot yet tell that nothing is left docked at the bottom. + // It flips one run loop later, while the keyboard is still animating + // away, so re-check then and borrow the keyboard's own duration: the web + // view grows alongside the keyboard instead of leaving a gap that snaps + // shut once the keyboard has gone. + let duration = notification.userInfo?[ + UIResponder.keyboardAnimationDurationUserInfoKey + ] as? TimeInterval ?? 0.25 + DispatchQueue.main.async { [weak self] in + guard let self, self.updateWebViewBottomConstraint() else { return } + UIView.animate(withDuration: duration) { + self.view.layoutIfNeeded() + } + } + } + + /// Final safety net for hosts where the check above still runs too early. + @objc private func keyboardDidHide() { + isKeyboardVisible = false + updateWebViewBottomConstraint() + } + + /// - Returns: Whether the active constraint changed. + @discardableResult + private func updateWebViewBottomConstraint() -> Bool { + let isFindNavigatorVisible = webView.findInteraction?.isFindNavigatorVisible ?? false + let shouldTrackKeyboard = isKeyboardVisible || isFindNavigatorVisible + guard shouldTrackKeyboard != webViewBottomToKeyboard.isActive else { return false } + + if shouldTrackKeyboard { + webViewBottomToSafeArea.isActive = false + webViewBottomToKeyboard.isActive = true + } else { + webViewBottomToKeyboard.isActive = false + webViewBottomToSafeArea.isActive = true + } + return true + } + public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) removeNavigationOverlay() @@ -568,6 +671,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.redo();") } + public func presentFindNavigator() { + webView.findInteraction?.presentFindNavigator(showingReplace: false) + } + /// Dismisses the topmost modal dialog or menu in the editor public func dismissTopModal() { evaluate("editor.dismissTopModal();")