From 84176cb1130e56fadbd7384ecc99f89ddeef6972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Mon, 12 Jan 2026 11:08:13 +0100 Subject: [PATCH] Hide keyboard and selection icons when Scribe mobile modal opened When opening Scribe mobile modal, we now unfocus the composer to hide the keyboard and the selection start and end icons from the OS. Add new methods to save, restore, and clear text selection in HTML editor. These methods allow preserving and restoring user selection state when opening the Scribe mobile modal so that the "replace" action still works. --- core/lib/utils/html/html_utils.dart | 34 +++++++ ...andle_ai_scribe_in_composer_extension.dart | 90 +++++++++++++++++++ .../composer_ai_scribe_selection_overlay.dart | 7 +- .../button/inline_ai_assist_button.dart | 5 +- .../widgets/overlay/ai_selection_overlay.dart | 3 +- 5 files changed, 135 insertions(+), 4 deletions(-) diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 5f2985149..45d9d4619 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -214,6 +214,40 @@ class HtmlUtils { })();''', name: 'deleteSelectionContent'); + static const saveSelection = ( + script: ''' + (() => { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + window._savedRange = selection.getRangeAt(0).cloneRange(); + return true; + } + return false; + })();''', + name: 'saveSelection'); + + static const restoreSelection = ( + script: ''' + (() => { + if (window._savedRange) { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(window._savedRange); + return true; + } + } + return false; + })();''', + name: 'restoreSelection'); + + static const clearSavedSelection = ( + script: ''' + (() => { + delete window._savedRange; + })();''', + name: 'clearSavedSelection'); + static recalculateEditorHeight({double? maxHeight}) => ( script: ''' const editable = document.querySelector('.note-editable'); diff --git a/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart b/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart index 0304f1ccb..7b3e5c094 100644 --- a/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart +++ b/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart @@ -71,6 +71,83 @@ extension HandleAiScribeInComposerExtension on ComposerController { } } + Future saveSelection() async { + try { + if (PlatformInfo.isWeb) { + final result = await richTextWebController?.editorController.evaluateJavascriptWeb( + HtmlUtils.saveSelection.name, + hasReturnValue: true, + ) ?? false; + return result; + } else { + final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController + .evaluateJavascript( + source: HtmlUtils.saveSelection.script, + ) ?? false; + return result; + } + } catch (e) { + logError('$runtimeType::saveSelection:Exception = $e'); + return false; + } + } + + Future restoreSelection() async { + try { + if (PlatformInfo.isWeb) { + final result = await richTextWebController?.editorController.evaluateJavascriptWeb( + HtmlUtils.restoreSelection.name, + hasReturnValue: true, + ) ?? false; + return result; + } else { + final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController + .evaluateJavascript( + source: HtmlUtils.restoreSelection.script, + ) ?? false; + return result; + } + } catch (e) { + logError('$runtimeType::restoreSelection:Exception = $e'); + return false; + } + } + + Future clearSavedSelection() async { + try { + if (PlatformInfo.isWeb) { + await richTextWebController?.editorController.evaluateJavascriptWeb( + HtmlUtils.clearSavedSelection.name, + hasReturnValue: false, + ); + } else { + await richTextMobileTabletController?.htmlEditorApi?.webViewController + .evaluateJavascript( + source: HtmlUtils.clearSavedSelection.script, + ); + } + } catch (e) { + logError('$runtimeType::clearSavedSelection:Exception = $e'); + } + } + + Future unfocusEditor() async { + final editorApi = richTextMobileTabletController?.htmlEditorApi; + if (PlatformInfo.isIOS) { + await editorApi?.unfocus(); + } else if (PlatformInfo.isAndroid) { + await editorApi?.hideKeyboard(); + await editorApi?.unfocus(); + } + } + + Future saveAndUnfocusForModal() async { + final saved = await saveSelection(); + if (saved) { + await unfocusEditor(); + } + } + void clearTextInEditor() { try { if (PlatformInfo.isWeb) { @@ -92,6 +169,11 @@ extension HandleAiScribeInComposerExtension on ComposerController { // If there is a selection, it will replace the selection, else it will replace everything Future onReplaceTextCallback(String text) async { final selection = editorTextSelection.value?.selectedText; + + if (PlatformInfo.isMobile) { + await restoreSelection(); + } + if (selection == null || selection.isEmpty) { clearTextInEditor(); } @@ -103,6 +185,10 @@ extension HandleAiScribeInComposerExtension on ComposerController { clearFocusRecipients(); clearFocusSubject(); + if (PlatformInfo.isMobile) { + await saveAndUnfocusForModal(); + } + final fullText = await _getTextOnlyContentInEditor(); await AiScribeModalManager.showAIScribeMenuModal( @@ -129,6 +215,10 @@ extension HandleAiScribeInComposerExtension on ComposerController { await onInsertTextCallback(suggestionText); break; } + + if (PlatformInfo.isMobile) { + await clearSavedSelection(); + } } void handleTextSelection(TextSelectionData? textSelectionData) { diff --git a/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart b/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart index eb859653b..46b072066 100644 --- a/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart +++ b/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:core/utils/platform_info.dart'; import 'package:scribe/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart'; import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart'; @@ -29,8 +30,12 @@ class ComposerAiScribeSelectionOverlay extends StatelessWidget { }); } - void _clearComposerInputFocus() { + Future _clearComposerInputFocus() async { controller.clearFocusRecipients(); controller.clearFocusSubject(); + + if (PlatformInfo.isMobile) { + await controller.saveAndUnfocusForModal(); + } } } diff --git a/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart b/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart index 06d3524ad..2ec3fa582 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart @@ -1,5 +1,6 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:scribe/scribe.dart'; @@ -7,7 +8,7 @@ class InlineAiAssistButton extends StatelessWidget { final ImagePaths imagePaths; final String? selectedText; final OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction; - final VoidCallback? onTapFallback; + final AsyncCallback? onTapFallback; const InlineAiAssistButton({ super.key, @@ -42,7 +43,7 @@ class InlineAiAssistButton extends StatelessWidget { size = renderBox.size; } - onTapFallback?.call(); + await onTapFallback?.call(); await AiScribeModalManager.showAIScribeMenuModal( imagePaths: imagePaths, diff --git a/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart b/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart index ea0621c4d..ba148bee5 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart @@ -1,4 +1,5 @@ import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:scribe/scribe.dart'; @@ -15,7 +16,7 @@ class AiSelectionOverlay extends StatelessWidget { final TextSelectionModel? selection; final ImagePaths imagePaths; final OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction; - final VoidCallback? onTapFallback; + final AsyncCallback? onTapFallback; @override Widget build(BuildContext context) {