From 33140f55f6279e623b2361c1747d2b62727cb32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 10 Dec 2025 14:25:32 +0100 Subject: [PATCH 1/4] Add JS method to collapse selection in both composer Collapsing a selection means removing it and keep the cursor at the end. It will be needed by the scribe for "Insert" action where we want to insert a text at the cursor even if the user selected text. --- core/lib/utils/html/html_utils.dart | 10 ++++++++++ .../presentation/widgets/web/web_editor_widget.dart | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 00dab4b696..3701375328 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -111,6 +111,16 @@ class HtmlUtils { });''', name: 'onSelectionChange'); + static const collapseSelectionToEnd = ( + script: ''' + (() => { + const selection = window.getSelection(); + if (selection) { + selection.collapseToEnd() + } + })();''', + name: 'collapseSelectionToEnd'); + static recalculateEditorHeight({double? maxHeight}) => ( script: ''' const editable = document.querySelector('.note-editable'); diff --git a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart index d2ecc749fd..7a6e1ff550 100644 --- a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart +++ b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart @@ -185,6 +185,10 @@ class _WebEditorState extends State with TextSelectionMixin { name: HtmlUtils.registerSelectionChangeListener.name, script: HtmlUtils.registerSelectionChangeListener.script, ), + WebScript( + name: HtmlUtils.collapseSelectionToEnd.name, + script: HtmlUtils.collapseSelectionToEnd.script, + ), WebScript( name: HtmlUtils.recalculateEditorHeight(maxHeight: maxHeight).name, script: HtmlUtils.recalculateEditorHeight(maxHeight: maxHeight).script, From f06a49df6a3c6723041a8628c9631c43cdc21f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 10 Dec 2025 14:49:57 +0100 Subject: [PATCH 2/4] Add an optional replace button in AI scribe suggestion If at AI scribe creation, we pass a onReplace callback, it will show on "Replace" button next to "Insert" button. --- scribe/lib/scribe/ai/l10n/intl_en.arb | 6 ++++ scribe/lib/scribe/ai/l10n/intl_fr.arb | 6 ++++ scribe/lib/scribe/ai/l10n/intl_messages.arb | 6 ++++ scribe/lib/scribe/ai/l10n/intl_ru.arb | 6 ++++ scribe/lib/scribe/ai/l10n/intl_vi.arb | 6 ++++ .../localizations/scribe_localizations.dart | 5 ++++ .../ai/presentation/widgets/ai_scribe.dart | 5 ++++ .../widgets/ai_scribe_suggestion.dart | 30 ++++++++++++++----- 8 files changed, 63 insertions(+), 7 deletions(-) diff --git a/scribe/lib/scribe/ai/l10n/intl_en.arb b/scribe/lib/scribe/ai/l10n/intl_en.arb index 72657c82ad..45384b6447 100644 --- a/scribe/lib/scribe/ai/l10n/intl_en.arb +++ b/scribe/lib/scribe/ai/l10n/intl_en.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Replace", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/scribe/lib/scribe/ai/l10n/intl_fr.arb b/scribe/lib/scribe/ai/l10n/intl_fr.arb index 6d910f946f..42626526e4 100644 --- a/scribe/lib/scribe/ai/l10n/intl_fr.arb +++ b/scribe/lib/scribe/ai/l10n/intl_fr.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Remplacer", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/l10n/intl_messages.arb b/scribe/lib/scribe/ai/l10n/intl_messages.arb index 89da188c00..3fb6e1cb67 100644 --- a/scribe/lib/scribe/ai/l10n/intl_messages.arb +++ b/scribe/lib/scribe/ai/l10n/intl_messages.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Replace", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/scribe/lib/scribe/ai/l10n/intl_ru.arb b/scribe/lib/scribe/ai/l10n/intl_ru.arb index bb406d97b4..8e1e9fc16f 100644 --- a/scribe/lib/scribe/ai/l10n/intl_ru.arb +++ b/scribe/lib/scribe/ai/l10n/intl_ru.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "заменить", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/l10n/intl_vi.arb b/scribe/lib/scribe/ai/l10n/intl_vi.arb index 3fa174a214..04b7e9da13 100644 --- a/scribe/lib/scribe/ai/l10n/intl_vi.arb +++ b/scribe/lib/scribe/ai/l10n/intl_vi.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Thay thế", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/localizations/scribe_localizations.dart b/scribe/lib/scribe/ai/localizations/scribe_localizations.dart index 5c6e4f90a6..a680d6187a 100644 --- a/scribe/lib/scribe/ai/localizations/scribe_localizations.dart +++ b/scribe/lib/scribe/ai/localizations/scribe_localizations.dart @@ -121,6 +121,11 @@ class ScribeLocalizations { return Intl.message('Insert', name: 'insertButton'); } + + String get replaceButton { + return Intl.message('Replace', + name: 'replaceButton'); + } } class _ScribeLocalizationsDelegate extends LocalizationsDelegate { diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart index 48effa9471..247205f979 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart @@ -18,6 +18,7 @@ Future showAIScribeDialog({ required ImagePaths imagePaths, required String content, required AIScribeResultCallback onInsertText, + AIScribeResultCallback? onReplaceText, required GenerateAITextInteractor interactor, List? availableCategories, Offset? buttonPosition, @@ -124,6 +125,10 @@ Future showAIScribeDialog({ onInsertText(result); Navigator.of(context).pop(); }, + onReplace: onReplaceText != null ? (result) { + onReplaceText(result); + Navigator.of(context).pop(); + } : null, imagePaths: imagePaths, ); diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart index 520a9c8578..e71e8532e9 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart @@ -13,6 +13,7 @@ class AIScribeSuggestion extends StatefulWidget { final Future suggestionFuture; final VoidCallback onClose; final OnInsertTextCallback onInsert; + final OnInsertTextCallback? onReplace; final ImagePaths imagePaths; const AIScribeSuggestion({ @@ -21,6 +22,7 @@ class AIScribeSuggestion extends StatefulWidget { required this.suggestionFuture, required this.onClose, required this.onInsert, + this.onReplace, required this.imagePaths, }); @@ -184,13 +186,27 @@ class _AIScribeSuggestionModalState extends State { Clipboard.setData(ClipboardData(text: suggestion)); }, ), - TMailButtonWidget( - text: ScribeLocalizations.of(context)!.insertButton, - textStyle: AIScribeButtonStyles.mainActionButtonText, - padding: AIScribeButtonStyles.mainActionButtonPadding, - backgroundColor: AIScribeButtonStyles.mainActionButtonBackgroundColor, - onTapActionCallback: () => widget.onInsert(suggestion), - ) + Row( + children: [ + if (widget.onReplace != null) ...[ + TMailButtonWidget( + text: ScribeLocalizations.of(context)!.replaceButton, + textStyle: AIScribeButtonStyles.mainActionButtonText, + padding: AIScribeButtonStyles.mainActionButtonPadding, + backgroundColor: Colors.transparent, + onTapActionCallback: () => widget.onReplace!(suggestion), + ), + const SizedBox(width: AIScribeSizes.fieldSpacing), + ], + TMailButtonWidget( + text: ScribeLocalizations.of(context)!.insertButton, + textStyle: AIScribeButtonStyles.mainActionButtonText, + padding: AIScribeButtonStyles.mainActionButtonPadding, + backgroundColor: AIScribeButtonStyles.mainActionButtonBackgroundColor, + onTapActionCallback: () => widget.onInsert(suggestion), + ), + ], + ), ], ), ); From a316c7233e35f3ccff58d16a822412992dad345d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 10 Dec 2025 14:52:30 +0100 Subject: [PATCH 3/4] Add method to collapse selection and insert/replace callback By leveraging selection collapsing provided by JS in both composer, we can now differentiate two actions that will be used by the scribe: - inserting: always insert at end of cursor - replacing: replace the content (selection wide or editor wide) --- .../mixin/ai_scribe_in_composer_mixin.dart | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart b/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart index ea345299d7..f689dd40c7 100644 --- a/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart +++ b/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart @@ -1,4 +1,5 @@ import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/utils/html/html_utils.dart'; import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -29,6 +30,42 @@ mixin AIScribeInComposerMixin { } } + Future collapseSelection() async { + if (PlatformInfo.isWeb) { + await richTextWebController?.editorController.evaluateJavascriptWeb( + HtmlUtils.collapseSelectionToEnd.name, + hasReturnValue: false, + ); + } else { + await richTextMobileTabletController?.htmlEditorApi?.webViewController.evaluateJavascript(source: HtmlUtils.collapseSelectionToEnd.script); + } + } + + void clearTextInEditor() { + if (PlatformInfo.isWeb) { + richTextWebController?.editorController.setText(''); + } else { + richTextMobileTabletController?.htmlEditorApi?.setText(''); + } + } + + // Ensure we only insert at cursor position by collapsing selection before inserting + void onInsertTextCallback(String text) async { + await collapseSelection(); + + insertTextInEditor(text); + } + + // If there is a selection, it will replace the selection, else it will replace everything + void onReplaceTextCallback(String text) async { + final selection = editorTextSelection.value?.selectedText; + if (selection == null || selection.isEmpty) { + clearTextInEditor(); + } + + insertTextInEditor(text); + } + void showAIScribeMenuForFullText(BuildContext context) async { final fullText = await getTextOnlyContentInEditor(); From 08b23dd25671a4201892e171a71d885fb96fcd7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 10 Dec 2025 15:13:03 +0100 Subject: [PATCH 4/4] Use appropriate callbacks for AI scribe actions If we open from selection: insert + replace (replace selection) If open from bottom bar with content : insert + replace (replace everything) If open from bottom bar with empty content : insert --- .../presentation/mixin/ai_scribe_in_composer_mixin.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart b/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart index f689dd40c7..5f8bd1b5db 100644 --- a/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart +++ b/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart @@ -81,7 +81,8 @@ mixin AIScribeInComposerMixin { context: context, imagePaths: imagePaths, content: fullText, - onInsertText: insertTextInEditor, + onInsertText: onInsertTextCallback, + onReplaceText: fullText.isEmpty ? null : onReplaceTextCallback, interactor: generateAITextInteractor, buttonPosition: buttonPosition, ); @@ -97,7 +98,8 @@ mixin AIScribeInComposerMixin { context: context, imagePaths: imagePaths, content: selection, - onInsertText: insertTextInEditor, + onInsertText: onInsertTextCallback, + onReplaceText: onReplaceTextCallback, interactor: generateAITextInteractor, buttonPosition: buttonPosition, );