Przeglądaj źródła

Merge pull request #1991 from AppFlowy-IO/fix_0_1_1

Fix release/0.1.1 known issues.
Lucas.Xu 2 lat temu
rodzic
commit
9a4cde07b6
16 zmienionych plików z 320 dodań i 110 usunięć
  1. 4 2
      frontend/appflowy_flutter/assets/translations/en.json
  2. 1 3
      frontend/appflowy_flutter/lib/plugins/document/document_page.dart
  3. 3 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart
  4. 8 35
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart
  5. 2 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart
  6. 19 42
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart
  7. 42 3
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart
  8. 4 3
      frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart
  9. 4 1
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart
  10. 93 14
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart
  11. 3 1
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart
  12. 1 0
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart
  13. 2 1
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart
  14. 132 0
      frontend/appflowy_flutter/packages/appflowy_editor/test/core/transform/transaction_test.dart
  15. 1 1
      frontend/appflowy_flutter/pubspec.lock
  16. 1 0
      frontend/appflowy_flutter/pubspec.yaml

+ 4 - 2
frontend/appflowy_flutter/assets/translations/en.json

@@ -344,16 +344,18 @@
       "referencedGrid": "Referenced Grid",
       "autoCompletionMenuItemName": "Auto Completion",
       "autoGeneratorMenuItemName": "Auto Generator",
-      "autoGeneratorTitleName": "Open AI: Auto Generator",
+      "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...",
       "autoGeneratorLearnMore": "Learn more",
       "autoGeneratorGenerate": "Generate",
       "autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
       "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key",
-      "smartEditTitleName": "Open AI: Smart Edit",
+      "smartEdit": "Smart Edit",
+      "smartEditTitleName": "OpenAI: Smart Edit",
       "smartEditFixSpelling": "Fix spelling",
       "smartEditSummarize": "Summarize",
       "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI",
       "smartEditCouldNotFetchKey": "Could not fetch OpenAI key",
+      "smartEditDisabled": "Connect OpenAI in Settings",
       "cover": {
         "changeCover": "Change Cover",
         "colors": "Colors",

+ 1 - 3
frontend/appflowy_flutter/lib/plugins/document/document_page.dart

@@ -183,9 +183,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
         ],
       ],
       toolbarItems: [
-        if (openAIKey != null && openAIKey!.isNotEmpty) ...[
-          smartEditItem,
-        ]
+        smartEditItem,
       ],
       themeData: theme.copyWith(extensions: [
         ...theme.extensions.values,

+ 3 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart

@@ -16,6 +16,7 @@ import 'package:flowy_infra_ui/style_widget/icon_button.dart';
 import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flutter/material.dart';
 import 'package:shared_preferences/shared_preferences.dart';
+import 'package:path/path.dart' as path;
 
 const String kLocalImagesKey = 'local_images';
 
@@ -263,7 +264,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
       if (path != null) {
         final directory = await _coverPath();
         final newPath = await File(path).copy(
-          '$directory/${path.split('/').last}',
+          '$directory/${path.split(path).last}}',
         );
         imageNames.add(newPath.path);
       }
@@ -274,7 +275,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
 
   Future<String> _coverPath() async {
     final directory = await getIt<SettingsLocationCubit>().fetchLocation();
-    return Directory('$directory/covers')
+    return Directory(path.join(directory, 'covers'))
         .create(recursive: true)
         .then((value) => value.path);
   }

+ 8 - 35
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart

@@ -9,7 +9,6 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flowy_infra_ui/style_widget/text_field.dart';
 import 'package:flowy_infra_ui/widget/spacing.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 import 'package:http/http.dart' as http;
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -127,26 +126,12 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
   }
 
   Widget _buildInputWidget(BuildContext context) {
-    return RawKeyboardListener(
-      focusNode: focusNode,
-      onKey: (RawKeyEvent event) async {
-        if (event is! RawKeyDownEvent) return;
-        if (event.logicalKey == LogicalKeyboardKey.enter) {
-          if (controller.text.isNotEmpty) {
-            textFieldFocusNode.unfocus();
-            await _onGenerate();
-          }
-        } else if (event.logicalKey == LogicalKeyboardKey.escape) {
-          await _onExit();
-        }
-      },
-      child: FlowyTextField(
-        hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
-        controller: controller,
-        maxLines: 3,
-        focusNode: textFieldFocusNode,
-        autoFocus: false,
-      ),
+    return FlowyTextField(
+      hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
+      controller: controller,
+      maxLines: 3,
+      focusNode: textFieldFocusNode,
+      autoFocus: false,
     );
   }
 
@@ -157,15 +142,9 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
           TextSpan(
             children: [
               TextSpan(
-                text: '${LocaleKeys.button_generate.tr()}  ',
+                text: LocaleKeys.button_generate.tr(),
                 style: Theme.of(context).textTheme.bodyMedium,
               ),
-              TextSpan(
-                text: '↵',
-                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
-                      color: Colors.grey,
-                    ),
-              ),
             ],
           ),
           onPressed: () async => await _onGenerate(),
@@ -175,15 +154,9 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
           TextSpan(
             children: [
               TextSpan(
-                text: '${LocaleKeys.button_Cancel.tr()}  ',
+                text: LocaleKeys.button_Cancel.tr(),
                 style: Theme.of(context).textTheme.bodyMedium,
               ),
-              TextSpan(
-                text: LocaleKeys.button_esc.tr(),
-                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
-                      color: Colors.grey,
-                    ),
-              ),
             ],
           ),
           onPressed: () async => await _onExit(),

+ 2 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart

@@ -10,9 +10,9 @@ enum SmartEditAction {
   String get toInstruction {
     switch (this) {
       case SmartEditAction.summarize:
-        return 'Make it shorter';
+        return 'Make this shorter and more concise:';
       case SmartEditAction.fixSpelling:
-        return 'Fix all the spelling mistakes';
+        return 'Correct this to standard English:';
     }
   }
 }

+ 19 - 42
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart

@@ -8,7 +8,6 @@ import 'package:flowy_infra_ui/style_widget/button.dart';
 import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flowy_infra_ui/widget/spacing.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:http/http.dart' as http;
@@ -99,28 +98,16 @@ class _SmartEditInputState extends State<_SmartEditInput> {
   }
 
   Widget _buildSmartEditPanel(BuildContext context) {
-    return RawKeyboardListener(
-      focusNode: focusNode,
-      onKey: (RawKeyEvent event) async {
-        if (event is! RawKeyDownEvent) return;
-        if (event.logicalKey == LogicalKeyboardKey.enter) {
-          await _onReplace();
-          await _onExit();
-        } else if (event.logicalKey == LogicalKeyboardKey.escape) {
-          await _onExit();
-        }
-      },
-      child: Column(
-        mainAxisSize: MainAxisSize.min,
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          _buildHeaderWidget(context),
-          const Space(0, 10),
-          _buildResultWidget(context),
-          const Space(0, 10),
-          _buildInputFooterWidget(context),
-        ],
-      ),
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        _buildHeaderWidget(context),
+        const Space(0, 10),
+        _buildResultWidget(context),
+        const Space(0, 10),
+        _buildInputFooterWidget(context),
+      ],
     );
   }
 
@@ -140,9 +127,12 @@ class _SmartEditInputState extends State<_SmartEditInput> {
   }
 
   Widget _buildResultWidget(BuildContext context) {
-    final loading = SizedBox.fromSize(
-      size: const Size.square(14),
-      child: const CircularProgressIndicator(),
+    final loading = Padding(
+      padding: const EdgeInsets.symmetric(horizontal: 4.0),
+      child: SizedBox.fromSize(
+        size: const Size.square(14),
+        child: const CircularProgressIndicator(),
+      ),
     );
     if (result == null) {
       return loading;
@@ -172,15 +162,9 @@ class _SmartEditInputState extends State<_SmartEditInput> {
           TextSpan(
             children: [
               TextSpan(
-                text: '${LocaleKeys.button_replace.tr()}  ',
+                text: LocaleKeys.button_replace.tr(),
                 style: Theme.of(context).textTheme.bodyMedium,
               ),
-              TextSpan(
-                text: '↵',
-                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
-                      color: Colors.grey,
-                    ),
-              ),
             ],
           ),
           onPressed: () {
@@ -193,15 +177,9 @@ class _SmartEditInputState extends State<_SmartEditInput> {
           TextSpan(
             children: [
               TextSpan(
-                text: '${LocaleKeys.button_Cancel.tr()}  ',
+                text: LocaleKeys.button_Cancel.tr(),
                 style: Theme.of(context).textTheme.bodyMedium,
               ),
-              TextSpan(
-                text: LocaleKeys.button_esc.tr(),
-                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
-                      color: Colors.grey,
-                    ),
-              ),
             ],
           ),
           onPressed: () async => await _onExit(),
@@ -222,7 +200,6 @@ class _SmartEditInputState extends State<_SmartEditInput> {
 
     final texts = result!.asRight().choices.first.text.split('\n')
       ..removeWhere((element) => element.isEmpty);
-    assert(texts.length == selectedNodes.length);
     final transaction = widget.editorState.transaction;
     transaction.replaceTexts(
       selectedNodes.toList(growable: false),
@@ -254,7 +231,7 @@ class _SmartEditInputState extends State<_SmartEditInput> {
       final edits = await openAIRepository.getEdits(
         input: input,
         instruction: instruction,
-        n: input.split('\n').length,
+        n: 1,
       );
       return edits.fold((error) async {
         return dartz.Left(

+ 42 - 3
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart

@@ -1,10 +1,14 @@
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
+import 'package:appflowy/user/application/user_service.dart';
 import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flutter/material.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
 
 ToolbarItem smartEditItem = ToolbarItem(
   id: 'appflowy.toolbar.smart_edit',
@@ -12,7 +16,8 @@ ToolbarItem smartEditItem = ToolbarItem(
   validator: (editorState) {
     // All selected nodes must be text.
     final nodes = editorState.service.selectionService.currentSelectedNodes;
-    return nodes.whereType<TextNode>().length == nodes.length;
+    return nodes.whereType<TextNode>().length == nodes.length &&
+        nodes.length == 1;
   },
   itemBuilder: (context, editorState) {
     return _SmartEditWidget(
@@ -33,6 +38,20 @@ class _SmartEditWidget extends StatefulWidget {
 }
 
 class _SmartEditWidgetState extends State<_SmartEditWidget> {
+  bool isOpenAIEnabled = false;
+
+  @override
+  void initState() {
+    super.initState();
+
+    UserBackendService.getCurrentUserProfile().then((value) {
+      setState(() {
+        isOpenAIEnabled =
+            value.fold((l) => l.openaiKey.isNotEmpty, (r) => false);
+      });
+    });
+  }
+
   @override
   Widget build(BuildContext context) {
     return PopoverActionList<SmartEditActionWrapper>(
@@ -43,7 +62,9 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> {
       buildChild: (controller) {
         return FlowyIconButton(
           hoverColor: Colors.transparent,
-          tooltipText: 'Smart Edit',
+          tooltipText: isOpenAIEnabled
+              ? LocaleKeys.document_plugins_smartEdit.tr()
+              : LocaleKeys.document_plugins_smartEditDisabled.tr(),
           preferBelow: false,
           icon: const Icon(
             Icons.lightbulb_outline,
@@ -51,7 +72,11 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> {
             color: Colors.white,
           ),
           onPressed: () {
-            controller.show();
+            if (isOpenAIEnabled) {
+              controller.show();
+            } else {
+              _showError(LocaleKeys.document_plugins_smartEditDisabled.tr());
+            }
           },
         );
       },
@@ -97,4 +122,18 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> {
       withUpdateCursor: false,
     );
   }
+
+  Future<void> _showError(String message) async {
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(
+        action: SnackBarAction(
+          label: LocaleKeys.button_Cancel.tr(),
+          onPressed: () {
+            ScaffoldMessenger.of(context).hideCurrentSnackBar();
+          },
+        ),
+        content: FlowyText(message),
+      ),
+    );
+  }
 }

+ 4 - 3
frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart

@@ -2,6 +2,7 @@ import 'dart:io';
 
 import 'package:appflowy_backend/appflowy_backend.dart';
 import 'package:path_provider/path_provider.dart';
+import 'package:path/path.dart' as path;
 
 import '../startup.dart';
 
@@ -35,11 +36,11 @@ Future<Directory> appFlowyDocumentDirectory() async {
   switch (integrationEnv()) {
     case IntegrationMode.develop:
       Directory documentsDir = await getApplicationDocumentsDirectory();
-      return Directory('${documentsDir.path}/flowy_dev').create();
+      return Directory(path.join(documentsDir.path, 'flowy_dev')).create();
     case IntegrationMode.release:
       Directory documentsDir = await getApplicationDocumentsDirectory();
-      return Directory('${documentsDir.path}/flowy').create();
+      return Directory(path.join(documentsDir.path, 'flowy')).create();
     case IntegrationMode.test:
-      return Directory("${Directory.current.path}/.sandbox");
+      return Directory(path.join(Directory.current.path, '.sandbox'));
   }
 }

+ 4 - 1
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart

@@ -57,7 +57,10 @@ extension CommandExtension on EditorState {
     Selection selection,
   ) {
     List<String> res = [];
-    if (!selection.isCollapsed) {
+    if (selection.isSingle) {
+      final plainText = textNodes.first.toPlainText();
+      res.add(plainText.substring(selection.startIndex, selection.endIndex));
+    } else if (!selection.isCollapsed) {
       for (var i = 0; i < textNodes.length; i++) {
         final plainText = textNodes[i].toPlainText();
         if (i == 0) {

+ 93 - 14
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart

@@ -291,46 +291,125 @@ extension TextTransaction on Transaction {
     Selection selection,
     List<String> texts,
   ) {
-    if (textNodes.isEmpty) {
+    if (textNodes.isEmpty || texts.isEmpty) {
       return;
     }
 
-    if (selection.isSingle) {
-      assert(textNodes.length == 1 && texts.length == 1);
-      replaceText(
-        textNodes.first,
-        selection.startIndex,
-        selection.length,
-        texts.first,
-      );
-    } else {
+    if (textNodes.length == texts.length) {
       final length = textNodes.length;
-      for (var i = 0; i < length; i++) {
+
+      if (length == 1) {
+        replaceText(
+          textNodes.first,
+          selection.startIndex,
+          selection.endIndex - selection.startIndex,
+          texts.first,
+        );
+        return;
+      }
+
+      for (var i = 0; i < textNodes.length; i++) {
         final textNode = textNodes[i];
-        final text = texts[i];
         if (i == 0) {
           replaceText(
             textNode,
             selection.startIndex,
             textNode.toPlainText().length,
-            text,
+            texts.first,
           );
         } else if (i == length - 1) {
           replaceText(
             textNode,
             0,
             selection.endIndex,
-            text,
+            texts.last,
           );
         } else {
           replaceText(
             textNode,
             0,
             textNode.toPlainText().length,
+            texts[i],
+          );
+        }
+      }
+      return;
+    }
+
+    if (textNodes.length > texts.length) {
+      final length = textNodes.length;
+      for (var i = 0; i < textNodes.length; i++) {
+        final textNode = textNodes[i];
+        if (i == 0) {
+          replaceText(
+            textNode,
+            selection.startIndex,
+            textNode.toPlainText().length,
+            texts.first,
+          );
+        } else if (i == length - 1) {
+          replaceText(
+            textNode,
+            0,
+            selection.endIndex,
+            texts.last,
+          );
+        } else {
+          if (i < texts.length - 1) {
+            replaceText(
+              textNode,
+              0,
+              textNode.toPlainText().length,
+              texts[i],
+            );
+          } else {
+            deleteNode(textNode);
+          }
+        }
+      }
+      afterSelection = null;
+      return;
+    }
+
+    if (textNodes.length < texts.length) {
+      final length = texts.length;
+      for (var i = 0; i < texts.length; i++) {
+        final text = texts[i];
+        if (i == 0) {
+          replaceText(
+            textNodes.first,
+            selection.startIndex,
+            textNodes.first.toPlainText().length,
             text,
           );
+        } else if (i == length - 1) {
+          replaceText(
+            textNodes.last,
+            0,
+            selection.endIndex,
+            text,
+          );
+        } else {
+          if (i < textNodes.length - 1) {
+            replaceText(
+              textNodes[i],
+              0,
+              textNodes[i].toPlainText().length,
+              text,
+            );
+          } else {
+            var path = textNodes.first.path;
+            var j = i - textNodes.length + length - 1;
+            while (j > 0) {
+              path = path.next;
+              j--;
+            }
+            insertNode(path, TextNode(delta: Delta()..insert(text)));
+          }
         }
       }
+      afterSelection = null;
+      return;
     }
   }
 }

+ 3 - 1
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart

@@ -8,7 +8,9 @@ ShortcutEventHandler selectAllHandler = (editorState, event) {
   if (editorState.document.root.children.isEmpty) {
     return KeyEventResult.handled;
   }
-  final firstNode = editorState.document.root.children.first;
+  final firstNode = editorState.document.root.children.firstWhere(
+    (element) => element is TextNode,
+  );
   final lastNode = editorState.document.root.children.last;
   var offset = 0;
   if (lastNode is TextNode) {

+ 1 - 0
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart

@@ -112,6 +112,7 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
     isFocus = false;
     this.showCursor = showCursor;
     _focusNode.unfocus(disposition: disposition);
+    _onFocusChange(false);
   }
 
   @override

+ 2 - 1
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -347,8 +347,9 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
   void _onPanStart(DragStartDetails details) {
     clearSelection();
+    _clearToolbar();
 
-    _panStartOffset = details.globalPosition;
+    _panStartOffset = details.globalPosition.translate(-3.0, 0);
     _panStartScrollDy = editorState.service.scrollService?.dy;
 
     _enableInteraction();

+ 132 - 0
frontend/appflowy_flutter/packages/appflowy_editor/test/core/transform/transaction_test.dart

@@ -0,0 +1,132 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+import '../../infra/test_editor.dart';
+
+Document createEmptyDocument() {
+  return Document(
+    root: Node(
+      type: 'editor',
+    ),
+  );
+}
+
+void main() async {
+  group('transaction.dart', () {
+    testWidgets('test replaceTexts, textNodes.length == texts.length',
+        (tester) async {
+      TestWidgetsFlutterBinding.ensureInitialized();
+
+      final editor = tester.editor
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789');
+      await editor.startTesting();
+      await tester.pumpAndSettle();
+
+      expect(editor.documentLength, 4);
+
+      final selection = Selection(
+        start: Position(path: [0], offset: 4),
+        end: Position(path: [3], offset: 4),
+      );
+      final transaction = editor.editorState.transaction;
+      var textNodes = [0, 1, 2, 3]
+          .map((e) => editor.nodeAtPath([e])!)
+          .whereType<TextNode>()
+          .toList(growable: false);
+      final texts = ['ABC', 'ABC', 'ABC', 'ABC'];
+      transaction.replaceTexts(textNodes, selection, texts);
+      editor.editorState.apply(transaction);
+      await tester.pumpAndSettle();
+
+      expect(editor.documentLength, 4);
+      textNodes = [0, 1, 2, 3]
+          .map((e) => editor.nodeAtPath([e])!)
+          .whereType<TextNode>()
+          .toList(growable: false);
+      expect(textNodes[0].toPlainText(), '0123ABC');
+      expect(textNodes[1].toPlainText(), 'ABC');
+      expect(textNodes[2].toPlainText(), 'ABC');
+      expect(textNodes[3].toPlainText(), 'ABC456789');
+    });
+
+    testWidgets('test replaceTexts, textNodes.length >  texts.length',
+        (tester) async {
+      TestWidgetsFlutterBinding.ensureInitialized();
+
+      final editor = tester.editor
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789');
+      await editor.startTesting();
+      await tester.pumpAndSettle();
+
+      expect(editor.documentLength, 5);
+
+      final selection = Selection(
+        start: Position(path: [0], offset: 4),
+        end: Position(path: [4], offset: 4),
+      );
+      final transaction = editor.editorState.transaction;
+      var textNodes = [0, 1, 2, 3, 4]
+          .map((e) => editor.nodeAtPath([e])!)
+          .whereType<TextNode>()
+          .toList(growable: false);
+      final texts = ['ABC', 'ABC', 'ABC', 'ABC'];
+      transaction.replaceTexts(textNodes, selection, texts);
+      editor.editorState.apply(transaction);
+      await tester.pumpAndSettle();
+
+      expect(editor.documentLength, 4);
+      textNodes = [0, 1, 2, 3]
+          .map((e) => editor.nodeAtPath([e])!)
+          .whereType<TextNode>()
+          .toList(growable: false);
+      expect(textNodes[0].toPlainText(), '0123ABC');
+      expect(textNodes[1].toPlainText(), 'ABC');
+      expect(textNodes[2].toPlainText(), 'ABC');
+      expect(textNodes[3].toPlainText(), 'ABC456789');
+    });
+
+    testWidgets('test replaceTexts, textNodes.length < texts.length',
+        (tester) async {
+      TestWidgetsFlutterBinding.ensureInitialized();
+
+      final editor = tester.editor
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789')
+        ..insertTextNode('0123456789');
+      await editor.startTesting();
+      await tester.pumpAndSettle();
+
+      expect(editor.documentLength, 3);
+
+      final selection = Selection(
+        start: Position(path: [0], offset: 4),
+        end: Position(path: [2], offset: 4),
+      );
+      final transaction = editor.editorState.transaction;
+      var textNodes = [0, 1, 2]
+          .map((e) => editor.nodeAtPath([e])!)
+          .whereType<TextNode>()
+          .toList(growable: false);
+      final texts = ['ABC', 'ABC', 'ABC', 'ABC'];
+      transaction.replaceTexts(textNodes, selection, texts);
+      editor.editorState.apply(transaction);
+      await tester.pumpAndSettle();
+
+      expect(editor.documentLength, 4);
+      textNodes = [0, 1, 2, 3]
+          .map((e) => editor.nodeAtPath([e])!)
+          .whereType<TextNode>()
+          .toList(growable: false);
+      expect(textNodes[0].toPlainText(), '0123ABC');
+      expect(textNodes[1].toPlainText(), 'ABC');
+      expect(textNodes[2].toPlainText(), 'ABC');
+      expect(textNodes[3].toPlainText(), 'ABC456789');
+    });
+  });
+}

+ 1 - 1
frontend/appflowy_flutter/pubspec.lock

@@ -830,7 +830,7 @@ packages:
     source: hosted
     version: "1.0.5"
   path:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: path
       url: "https://pub.dartlang.org"

+ 1 - 0
frontend/appflowy_flutter/pubspec.yaml

@@ -95,6 +95,7 @@ dependencies:
   window_manager: ^0.3.0
   http: ^0.13.5
   json_annotation: ^4.7.0
+  path: ^1.8.2
 
 dev_dependencies:
   flutter_lints: ^2.0.1