Kaynağa Gözat

Feature/smart edit v2 (#1880)

* feat: add edit api to openai client

* feat: add translation

* chore: format code

* feat: add smart edit plugin

* fix: close http.client when dispose

* fix: insert openai result to wrong position

* feat: optimize the replace text logic

* test: add test for normalize and getTextInSelection function

* chore: update error message
Lucas.Xu 2 yıl önce
ebeveyn
işleme
085ef8f668
22 değiştirilmiş dosya ile 701 ekleme ve 44 silme
  1. 8 2
      frontend/appflowy_flutter/assets/translations/en.json
  2. 3 2
      frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart
  3. 8 0
      frontend/appflowy_flutter/lib/plugins/document/document_page.dart
  4. 41 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart
  5. 24 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_edit.dart
  6. 2 3
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart
  7. 36 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart
  8. 277 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart
  9. 93 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart
  10. 0 1
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart
  11. 1 0
      frontend/appflowy_flutter/packages/appflowy_editor/lib/appflowy_editor.dart
  12. 19 0
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart
  13. 0 1
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/text/text_commands.dart
  14. 51 0
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart
  15. 14 0
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/extensions/node_extensions.dart
  16. 18 7
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  17. 20 17
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart
  18. 13 8
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart
  19. 1 1
      frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/toolbar_service.dart
  20. 36 0
      frontend/appflowy_flutter/packages/appflowy_editor/test/command/command_extension_test.dart
  21. 33 1
      frontend/appflowy_flutter/packages/appflowy_editor/test/extensions/node_extension_test.dart
  22. 3 0
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart

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

@@ -137,7 +137,8 @@
     "esc": "ESC",
     "keep": "Keep",
     "tryAgain": "Try again",
-    "discard": "Discard"
+    "discard": "Discard",
+    "replace": "Replace"
   },
   "label": {
     "welcome": "Welcome!",
@@ -347,7 +348,12 @@
       "autoGeneratorLearnMore": "Learn more",
       "autoGeneratorGenerate": "Generate",
       "autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
-      "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key"
+      "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key",
+      "smartEditTitleName": "Open AI: Smart Edit",
+      "smartEditFixSpelling": "Fix spelling",
+      "smartEditSummarize": "Summarize",
+      "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI",
+      "smartEditCouldNotFetchKey": "Could not fetch OpenAI key"
     }
   },
   "board": {

+ 3 - 2
frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart

@@ -80,8 +80,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
     if (userProfile.isRight()) {
       emit(
         state.copyWith(
-          loadingState:
-              DocumentLoadingState.finish(right(userProfile.asRight())),
+          loadingState: DocumentLoadingState.finish(
+            right(userProfile.asRight()),
+          ),
         ),
       );
       return;

+ 8 - 0
frontend/appflowy_flutter/lib/plugins/document/document_page.dart

@@ -4,6 +4,8 @@ import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_menu_it
 import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@@ -142,6 +144,8 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
         kCalloutType: CalloutNodeWidgetBuilder(),
         // Auto Generator,
         kAutoCompletionInputType: AutoCompletionInputBuilder(),
+        // Smart Edit,
+        kSmartEditType: SmartEditInputBuilder(),
       },
       shortcutEvents: [
         // Divider
@@ -172,6 +176,9 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
           autoGeneratorMenuItem,
         ]
       ],
+      toolbarItems: [
+        smartEditItem,
+      ],
       themeData: theme.copyWith(extensions: [
         ...theme.extensions.values,
         customEditorTheme(context),
@@ -203,6 +210,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
     }
     final temporaryNodeTypes = [
       kAutoCompletionInputType,
+      kSmartEditType,
     ];
     final iterator = NodeIterator(
       document: document,

+ 41 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart

@@ -1,5 +1,7 @@
 import 'dart:convert';
 
+import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
+
 import 'text_completion.dart';
 import 'package:dartz/dartz.dart';
 import 'dart:async';
@@ -38,6 +40,18 @@ abstract class OpenAIRepository {
     int maxTokens = 50,
     double temperature = .3,
   });
+
+  ///  Get edits from GPT-3
+  ///
+  /// [input] is the input text
+  /// [instruction] is the instruction text
+  /// [temperature] is the temperature of the model
+  ///
+  Future<Either<OpenAIError, TextEditResponse>> getEdits({
+    required String input,
+    required String instruction,
+    double temperature = 0.3,
+  });
 }
 
 class HttpOpenAIRepository implements OpenAIRepository {
@@ -70,7 +84,7 @@ class HttpOpenAIRepository implements OpenAIRepository {
       'stream': false,
     };
 
-    final response = await http.post(
+    final response = await client.post(
       OpenAIRequestType.textCompletion.uri,
       headers: headers,
       body: json.encode(parameters),
@@ -82,4 +96,30 @@ class HttpOpenAIRepository implements OpenAIRepository {
       return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
     }
   }
+
+  @override
+  Future<Either<OpenAIError, TextEditResponse>> getEdits({
+    required String input,
+    required String instruction,
+    double temperature = 0.3,
+  }) async {
+    final parameters = {
+      'model': 'text-davinci-edit-001',
+      'input': input,
+      'instruction': instruction,
+      'temperature': temperature,
+    };
+
+    final response = await client.post(
+      OpenAIRequestType.textEdit.uri,
+      headers: headers,
+      body: json.encode(parameters),
+    );
+
+    if (response.statusCode == 200) {
+      return Right(TextEditResponse.fromJson(json.decode(response.body)));
+    } else {
+      return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
+    }
+  }
 }

+ 24 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_edit.dart

@@ -0,0 +1,24 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+part 'text_edit.freezed.dart';
+part 'text_edit.g.dart';
+
+@freezed
+class TextEditChoice with _$TextEditChoice {
+  factory TextEditChoice({
+    required String text,
+    required int index,
+  }) = _TextEditChoice;
+
+  factory TextEditChoice.fromJson(Map<String, Object?> json) =>
+      _$TextEditChoiceFromJson(json);
+}
+
+@freezed
+class TextEditResponse with _$TextEditResponse {
+  const factory TextEditResponse({
+    required List<TextEditChoice> choices,
+  }) = _TextEditResponse;
+
+  factory TextEditResponse.fromJson(Map<String, Object?> json) =>
+      _$TextEditResponseFromJson(json);
+}

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

@@ -167,7 +167,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
                 text: '↵',
                 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                       color: Colors.grey,
-                    ), // FIXME: color
+                    ),
               ),
             ],
           ),
@@ -185,7 +185,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
                 text: LocaleKeys.button_esc.tr(),
                 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                       color: Colors.grey,
-                    ), // FIXME: color
+                    ),
               ),
             ],
           ),
@@ -198,7 +198,6 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
   Widget _buildFooterWidget(BuildContext context) {
     return Row(
       children: [
-        // FIXME: l10n
         FlowyRichTextButton(
           TextSpan(
             children: [

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

@@ -0,0 +1,36 @@
+import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
+import 'package:flutter/material.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
+
+enum SmartEditAction {
+  summarize,
+  fixSpelling;
+
+  String get toInstruction {
+    switch (this) {
+      case SmartEditAction.summarize:
+        return 'Summarize';
+      case SmartEditAction.fixSpelling:
+        return 'Fix the spelling mistakes';
+    }
+  }
+}
+
+class SmartEditActionWrapper extends ActionCell {
+  final SmartEditAction inner;
+
+  SmartEditActionWrapper(this.inner);
+
+  Widget? icon(Color iconColor) => null;
+
+  @override
+  String get name {
+    switch (inner) {
+      case SmartEditAction.summarize:
+        return LocaleKeys.document_plugins_smartEditSummarize.tr();
+      case SmartEditAction.fixSpelling:
+        return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
+    }
+  }
+}

+ 277 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart

@@ -0,0 +1,277 @@
+import 'package:appflowy/plugins/document/presentation/plugins/openai/service/error.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
+import 'package:appflowy/user/application/user_service.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+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;
+import 'package:dartz/dartz.dart' as dartz;
+import 'package:appflowy/util/either_extension.dart';
+
+const String kSmartEditType = 'smart_edit_input';
+const String kSmartEditInstructionType = 'smart_edit_instruction';
+const String kSmartEditInputType = 'smart_edit_input';
+
+class SmartEditInputBuilder extends NodeWidgetBuilder<Node> {
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return SmartEditAction.values.map((e) => e.toInstruction).contains(
+                  node.attributes[kSmartEditInstructionType],
+                ) &&
+            node.attributes[kSmartEditInputType] is String;
+      };
+
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return _SmartEditInput(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+}
+
+class _SmartEditInput extends StatefulWidget {
+  final Node node;
+
+  final EditorState editorState;
+  const _SmartEditInput({
+    Key? key,
+    required this.node,
+    required this.editorState,
+  });
+
+  @override
+  State<_SmartEditInput> createState() => _SmartEditInputState();
+}
+
+class _SmartEditInputState extends State<_SmartEditInput> {
+  String get instruction => widget.node.attributes[kSmartEditInstructionType];
+  String get input => widget.node.attributes[kSmartEditInputType];
+
+  final focusNode = FocusNode();
+  final client = http.Client();
+  dartz.Either<OpenAIError, TextEditResponse>? result;
+  bool loading = true;
+
+  @override
+  void initState() {
+    super.initState();
+
+    widget.editorState.service.keyboardService?.disable(showCursor: true);
+    focusNode.requestFocus();
+    focusNode.addListener(() {
+      if (!focusNode.hasFocus) {
+        widget.editorState.service.keyboardService?.enable();
+      }
+    });
+    _requestEdits().then(
+      (value) => setState(() {
+        result = value;
+        loading = false;
+      }),
+    );
+  }
+
+  @override
+  void dispose() {
+    client.close();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Card(
+      elevation: 5,
+      color: Theme.of(context).colorScheme.surface,
+      child: Container(
+        margin: const EdgeInsets.all(10),
+        child: _buildSmartEditPanel(context),
+      ),
+    );
+  }
+
+  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),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildHeaderWidget(BuildContext context) {
+    return Row(
+      children: [
+        FlowyText.medium(
+          LocaleKeys.document_plugins_smartEditTitleName.tr(),
+          fontSize: 14,
+        ),
+        const Spacer(),
+        FlowyText.regular(
+          LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
+        ),
+      ],
+    );
+  }
+
+  Widget _buildResultWidget(BuildContext context) {
+    final loading = SizedBox.fromSize(
+      size: const Size.square(14),
+      child: const CircularProgressIndicator(),
+    );
+    if (result == null) {
+      return loading;
+    }
+    return result!.fold((error) {
+      return Flexible(
+        child: Text(
+          error.message,
+          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+                color: Colors.red,
+              ),
+        ),
+      );
+    }, (response) {
+      return Flexible(
+        child: Text(
+          response.choices.map((e) => e.text).join('\n'),
+        ),
+      );
+    });
+  }
+
+  Widget _buildInputFooterWidget(BuildContext context) {
+    return Row(
+      children: [
+        FlowyRichTextButton(
+          TextSpan(
+            children: [
+              TextSpan(
+                text: '${LocaleKeys.button_replace.tr()}  ',
+                style: Theme.of(context).textTheme.bodyMedium,
+              ),
+              TextSpan(
+                text: '↵',
+                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+                      color: Colors.grey,
+                    ),
+              ),
+            ],
+          ),
+          onPressed: () {
+            _onReplace();
+            _onExit();
+          },
+        ),
+        const Space(10, 0),
+        FlowyRichTextButton(
+          TextSpan(
+            children: [
+              TextSpan(
+                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(),
+        ),
+      ],
+    );
+  }
+
+  Future<void> _onReplace() async {
+    final selection = widget.editorState.service.selectionService
+        .currentSelection.value?.normalized;
+    final selectedNodes = widget
+        .editorState.service.selectionService.currentSelectedNodes.normalized
+        .whereType<TextNode>();
+    if (selection == null || result == null || result!.isLeft()) {
+      return;
+    }
+
+    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),
+      selection,
+      texts,
+    );
+    return widget.editorState.apply(transaction);
+  }
+
+  Future<void> _onExit() async {
+    final transaction = widget.editorState.transaction;
+    transaction.deleteNode(widget.node);
+    return widget.editorState.apply(
+      transaction,
+      options: const ApplyOptions(
+        recordRedo: false,
+        recordUndo: false,
+      ),
+    );
+  }
+
+  Future<dartz.Either<OpenAIError, TextEditResponse>> _requestEdits() async {
+    final result = await UserBackendService.getCurrentUserProfile();
+    return result.fold((userProfile) async {
+      final openAIRepository = HttpOpenAIRepository(
+        client: client,
+        apiKey: userProfile.openaiKey,
+      );
+      final edits = await openAIRepository.getEdits(
+        input: input,
+        instruction: instruction,
+      );
+      return edits.fold((error) async {
+        return dartz.Left(
+          OpenAIError(
+            message:
+                LocaleKeys.document_plugins_smartEditCouldNotFetchResult.tr(),
+          ),
+        );
+      }, (textEdit) async {
+        return dartz.Right(textEdit);
+      });
+    }, (error) async {
+      // error
+      return dartz.Left(
+        OpenAIError(
+          message: LocaleKeys.document_plugins_smartEditCouldNotFetchKey.tr(),
+        ),
+      );
+    });
+  }
+}

+ 93 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart

@@ -0,0 +1,93 @@
+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/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:flutter/material.dart';
+
+ToolbarItem smartEditItem = ToolbarItem(
+  id: 'appflowy.toolbar.smart_edit',
+  type: 0, // headmost
+  validator: (editorState) {
+    // All selected nodes must be text.
+    final nodes = editorState.service.selectionService.currentSelectedNodes;
+    return nodes.whereType<TextNode>().length == nodes.length;
+  },
+  itemBuilder: (context, editorState) {
+    return _SmartEditWidget(
+      editorState: editorState,
+    );
+  },
+);
+
+class _SmartEditWidget extends StatefulWidget {
+  const _SmartEditWidget({
+    required this.editorState,
+  });
+
+  final EditorState editorState;
+
+  @override
+  State<_SmartEditWidget> createState() => _SmartEditWidgetState();
+}
+
+class _SmartEditWidgetState extends State<_SmartEditWidget> {
+  @override
+  Widget build(BuildContext context) {
+    return PopoverActionList<SmartEditActionWrapper>(
+      direction: PopoverDirection.bottomWithLeftAligned,
+      actions: SmartEditAction.values
+          .map((action) => SmartEditActionWrapper(action))
+          .toList(),
+      buildChild: (controller) {
+        return FlowyIconButton(
+          tooltipText: 'Smart Edit',
+          preferBelow: false,
+          icon: const Icon(
+            Icons.edit,
+            size: 14,
+          ),
+          onPressed: () {
+            controller.show();
+          },
+        );
+      },
+      onSelected: (action, controller) {
+        controller.close();
+        final selection =
+            widget.editorState.service.selectionService.currentSelection.value;
+        if (selection == null) {
+          return;
+        }
+        final textNodes = widget
+            .editorState.service.selectionService.currentSelectedNodes
+            .whereType<TextNode>()
+            .toList(growable: false);
+        final input = widget.editorState.getTextInSelection(
+          textNodes.normalized,
+          selection.normalized,
+        );
+        final transaction = widget.editorState.transaction;
+        transaction.insertNode(
+          selection.normalized.end.path.next,
+          Node(
+            type: kSmartEditType,
+            attributes: {
+              kSmartEditInstructionType: action.inner.toInstruction,
+              kSmartEditInputType: input,
+            },
+          ),
+        );
+        widget.editorState.apply(
+          transaction,
+          options: const ApplyOptions(
+            recordUndo: false,
+            recordRedo: false,
+          ),
+          withUpdateCursor: false,
+        );
+      },
+    );
+  }
+}

+ 0 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart

@@ -121,7 +121,6 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
         ),
       ),
       onSubmitted: (val) {
-        // TODO: validate key
         context
             .read<SettingsUserViewBloc>()
             .add(SettingsUserEvent.updateUserOpenAIKey(val));

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

@@ -43,6 +43,7 @@ export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart';
 export 'src/plugins/markdown/document_markdown.dart';
 export 'src/plugins/quill_delta/delta_document_encoder.dart';
 export 'src/commands/text/text_commands.dart';
+export 'src/commands/command_extension.dart';
 export 'src/render/toolbar/toolbar_item.dart';
 export 'src/extensions/node_extensions.dart';
 export 'src/render/action_menu/action_menu.dart';

+ 19 - 0
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart

@@ -51,4 +51,23 @@ extension CommandExtension on EditorState {
     }
     throw Exception('path and textNode cannot be null at the same time');
   }
+
+  String getTextInSelection(
+    List<TextNode> textNodes,
+    Selection selection,
+  ) {
+    List<String> res = [];
+    if (!selection.isCollapsed) {
+      for (var i = 0; i < textNodes.length; i++) {
+        if (i == 0) {
+          res.add(textNodes[i].toPlainText().substring(selection.startIndex));
+        } else if (i == textNodes.length - 1) {
+          res.add(textNodes[i].toPlainText().substring(0, selection.endIndex));
+        } else {
+          res.add(textNodes[i].toPlainText());
+        }
+      }
+    }
+    return res.join('\n');
+  }
 }

+ 0 - 1
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/text/text_commands.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor/src/commands/command_extension.dart';
 
 extension TextCommands on EditorState {
   /// Insert text at the given index of the given [TextNode] or the [Path].

+ 51 - 0
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart

@@ -266,6 +266,9 @@ extension TextTransaction on Transaction {
           textNode.delta.slice(max(index - 1, 0), index).first.attributes;
       if (newAttributes != null) {
         newAttributes = {...newAttributes}; // make a copy
+      } else {
+        newAttributes =
+            textNode.delta.slice(index, index + length).first.attributes;
       }
     }
     updateText(
@@ -282,4 +285,52 @@ extension TextTransaction on Transaction {
       ),
     );
   }
+
+  void replaceTexts(
+    List<TextNode> textNodes,
+    Selection selection,
+    List<String> texts,
+  ) {
+    if (textNodes.isEmpty) {
+      return;
+    }
+
+    if (selection.isSingle) {
+      assert(textNodes.length == 1 && texts.length == 1);
+      replaceText(
+        textNodes.first,
+        selection.startIndex,
+        selection.length,
+        texts.first,
+      );
+    } else {
+      final length = textNodes.length;
+      for (var i = 0; i < length; i++) {
+        final textNode = textNodes[i];
+        final text = texts[i];
+        if (i == 0) {
+          replaceText(
+            textNode,
+            selection.startIndex,
+            textNode.toPlainText().length,
+            text,
+          );
+        } else if (i == length - 1) {
+          replaceText(
+            textNode,
+            0,
+            selection.endIndex,
+            text,
+          );
+        } else {
+          replaceText(
+            textNode,
+            0,
+            textNode.toPlainText().length,
+            text,
+          );
+        }
+      }
+    }
+  }
 }

+ 14 - 0
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/extensions/node_extensions.dart

@@ -37,3 +37,17 @@ extension NodeExtensions on Node {
         currentSelectedNodes.first == this;
   }
 }
+
+extension NodesExtensions<T extends Node> on List<T> {
+  List<T> get normalized {
+    if (isEmpty) {
+      return this;
+    }
+
+    if (first.path > last.path) {
+      return reversed.toList();
+    }
+
+    return this;
+  }
+}

+ 18 - 7
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -20,20 +20,31 @@ class ToolbarItem {
   ToolbarItem({
     required this.id,
     required this.type,
-    required this.iconBuilder,
     this.tooltipsMessage = '',
+    this.iconBuilder,
     required this.validator,
-    required this.highlightCallback,
-    required this.handler,
-  });
+    this.highlightCallback,
+    this.handler,
+    this.itemBuilder,
+  }) {
+    assert(
+      (iconBuilder != null && itemBuilder == null) ||
+          (iconBuilder == null && itemBuilder != null),
+      'iconBuilder and itemBuilder must be set one of them',
+    );
+  }
 
   final String id;
   final int type;
-  final Widget Function(bool isHighlight) iconBuilder;
   final String tooltipsMessage;
   final ToolbarItemValidator validator;
-  final ToolbarItemEventHandler handler;
-  final ToolbarItemHighlightCallback highlightCallback;
+
+  final Widget Function(bool isHighlight)? iconBuilder;
+  final ToolbarItemEventHandler? handler;
+  final ToolbarItemHighlightCallback? highlightCallback;
+
+  final Widget Function(BuildContext context, EditorState editorState)?
+      itemBuilder;
 
   factory ToolbarItem.divider() {
     return ToolbarItem(

+ 20 - 17
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart

@@ -16,24 +16,27 @@ class ToolbarItemWidget extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return SizedBox(
-      width: 28,
-      height: 28,
-      child: Tooltip(
-        preferBelow: false,
-        message: item.tooltipsMessage,
-        child: MouseRegion(
-          cursor: SystemMouseCursors.click,
-          child: IconButton(
-            hoverColor: Colors.transparent,
-            highlightColor: Colors.transparent,
-            padding: EdgeInsets.zero,
-            icon: item.iconBuilder(isHighlight),
-            iconSize: 28,
-            onPressed: onPressed,
+    if (item.iconBuilder != null) {
+      return SizedBox(
+        width: 28,
+        height: 28,
+        child: Tooltip(
+          preferBelow: false,
+          message: item.tooltipsMessage,
+          child: MouseRegion(
+            cursor: SystemMouseCursors.click,
+            child: IconButton(
+              hoverColor: Colors.transparent,
+              highlightColor: Colors.transparent,
+              padding: EdgeInsets.zero,
+              icon: item.iconBuilder!(isHighlight),
+              iconSize: 28,
+              onPressed: onPressed,
+            ),
           ),
         ),
-      ),
-    );
+      );
+    }
+    return const SizedBox.shrink();
   }
 }

+ 13 - 8
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart

@@ -66,14 +66,19 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
             children: widget.items
                 .map(
                   (item) => Center(
-                    child: ToolbarItemWidget(
-                      item: item,
-                      isHighlight: item.highlightCallback(widget.editorState),
-                      onPressed: () {
-                        item.handler(widget.editorState, context);
-                        widget.editorState.service.keyboardService?.enable();
-                      },
-                    ),
+                    child:
+                        item.itemBuilder?.call(context, widget.editorState) ??
+                            ToolbarItemWidget(
+                              item: item,
+                              isHighlight: item.highlightCallback
+                                      ?.call(widget.editorState) ??
+                                  false,
+                              onPressed: () {
+                                item.handler?.call(widget.editorState, context);
+                                widget.editorState.service.keyboardService
+                                    ?.enable();
+                              },
+                            ),
                   ),
                 )
                 .toList(growable: false),

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

@@ -78,7 +78,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
       assert(items.length == 1, 'The toolbar item\'s id must be unique');
       return false;
     }
-    items.first.handler(widget.editorState, context);
+    items.first.handler?.call(widget.editorState, context);
     return true;
   }
 

+ 36 - 0
frontend/appflowy_flutter/packages/appflowy_editor/test/command/command_extension_test.dart

@@ -0,0 +1,36 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+import '../infra/test_editor.dart';
+
+void main() {
+  group('command_extension.dart', () {
+    testWidgets('insert a new checkbox after an exsiting checkbox',
+        (tester) async {
+      final editor = tester.editor
+        ..insertTextNode(
+          'Welcome',
+        )
+        ..insertTextNode(
+          'to',
+        )
+        ..insertTextNode(
+          'Appflowy 😁',
+        );
+      await editor.startTesting();
+      final selection = Selection(
+        start: Position(path: [2], offset: 5),
+        end: Position(path: [0], offset: 5),
+      );
+      await editor.updateSelection(selection);
+      final textNodes = editor
+          .editorState.service.selectionService.currentSelectedNodes
+          .whereType<TextNode>()
+          .toList(growable: false);
+      final text = editor.editorState.getTextInSelection(
+        textNodes.normalized,
+        selection.normalized,
+      );
+      expect(text, 'me\nto\nAppfl');
+    });
+  });
+}

+ 33 - 1
frontend/appflowy_flutter/packages/appflowy_editor/test/extensions/node_extension_test.dart

@@ -2,12 +2,13 @@ import 'dart:collection';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter_test/flutter_test.dart';
+import '../infra/test_editor.dart';
 import 'package:mockito/mockito.dart';
 
 class MockNode extends Mock implements Node {}
 
 void main() {
-  group('NodeExtensions::', () {
+  group('node_extension.dart', () {
     final selection = Selection(
       start: Position(path: [0]),
       end: Position(path: [1]),
@@ -43,5 +44,36 @@ void main() {
       final result = node.inSelection(selection);
       expect(result, false);
     });
+
+    testWidgets('insert a new checkbox after an exsiting checkbox',
+        (tester) async {
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor
+        ..insertTextNode(
+          text,
+        )
+        ..insertTextNode(
+          text,
+        )
+        ..insertTextNode(
+          text,
+        );
+      await editor.startTesting();
+      final selection = Selection(
+        start: Position(path: [2], offset: 5),
+        end: Position(path: [0], offset: 5),
+      );
+      await editor.updateSelection(selection);
+      final nodes =
+          editor.editorState.service.selectionService.currentSelectedNodes;
+      expect(
+        nodes.map((e) => e.path).toList().toString(),
+        '[[2], [1], [0]]',
+      );
+      expect(
+        nodes.normalized.map((e) => e.path).toList().toString(),
+        '[[0], [1], [2]]',
+      );
+    });
   });
 }

+ 3 - 0
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart

@@ -14,6 +14,7 @@ class FlowyIconButton extends StatelessWidget {
   final EdgeInsets iconPadding;
   final BorderRadius? radius;
   final String? tooltipText;
+  final bool preferBelow;
 
   const FlowyIconButton({
     Key? key,
@@ -25,6 +26,7 @@ class FlowyIconButton extends StatelessWidget {
     this.iconPadding = EdgeInsets.zero,
     this.radius,
     this.tooltipText,
+    this.preferBelow = true,
     required this.icon,
   }) : super(key: key);
 
@@ -44,6 +46,7 @@ class FlowyIconButton extends StatelessWidget {
       constraints:
           BoxConstraints.tightFor(width: size.width, height: size.height),
       child: Tooltip(
+        preferBelow: preferBelow,
         message: tooltipText ?? '',
         showDuration: Duration.zero,
         child: RawMaterialButton(