From 085ef8f668697081d92ae500810a9124e9b71966 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 28 Feb 2023 14:34:13 +0800 Subject: [PATCH] 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 --- .../assets/translations/en.json | 10 +- .../document/application/doc_bloc.dart | 5 +- .../lib/plugins/document/document_page.dart | 8 + .../plugins/openai/service/openai_client.dart | 42 ++- .../plugins/openai/service/text_edit.dart | 24 ++ .../widgets/auto_completion_node_widget.dart | 5 +- .../openai/widgets/smart_edit_action.dart | 36 +++ .../widgets/smart_edit_node_widget.dart | 277 ++++++++++++++++++ .../widgets/smart_edit_toolbar_item.dart | 93 ++++++ .../settings/widgets/settings_user_view.dart | 1 - .../appflowy_editor/lib/appflowy_editor.dart | 1 + .../lib/src/commands/command_extension.dart | 19 ++ .../lib/src/commands/text/text_commands.dart | 1 - .../lib/src/core/transform/transaction.dart | 51 ++++ .../lib/src/extensions/node_extensions.dart | 14 + .../lib/src/render/toolbar/toolbar_item.dart | 25 +- .../render/toolbar/toolbar_item_widget.dart | 37 +-- .../src/render/toolbar/toolbar_widget.dart | 21 +- .../lib/src/service/toolbar_service.dart | 2 +- .../test/command/command_extension_test.dart | 36 +++ .../test/extensions/node_extension_test.dart | 34 ++- .../lib/style_widget/icon_button.dart | 3 + 22 files changed, 701 insertions(+), 44 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_edit.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart create mode 100644 frontend/appflowy_flutter/packages/appflowy_editor/test/command/command_extension_test.dart diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index 07ffbfa223..576085d450 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/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": { diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 92533b80d4..5d0c0cd0f9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -80,8 +80,9 @@ class DocumentBloc extends Bloc { if (userProfile.isRight()) { emit( state.copyWith( - loadingState: - DocumentLoadingState.finish(right(userProfile.asRight())), + loadingState: DocumentLoadingState.finish( + right(userProfile.asRight()), + ), ), ); return; diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 31d425f20e..f23ace9995 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart index 772dda9fbf..f8b3b050fb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/openai_client.dart +++ b/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> 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> 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'])); + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_edit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/service/text_edit.dart new file mode 100644 index 0000000000..52cce9da4f --- /dev/null +++ b/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 json) => + _$TextEditChoiceFromJson(json); +} + +@freezed +class TextEditResponse with _$TextEditResponse { + const factory TextEditResponse({ + required List choices, + }) = _TextEditResponse; + + factory TextEditResponse.fromJson(Map json) => + _$TextEditResponseFromJson(json); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart index 911e8c5294..acbad03ea4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart +++ b/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: [ diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart new file mode 100644 index 0000000000..a7c2ef2626 --- /dev/null +++ b/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(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart new file mode 100644 index 0000000000..2ce7267a7e --- /dev/null +++ b/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 { + @override + NodeValidator get nodeValidator => (node) { + return SmartEditAction.values.map((e) => e.toInstruction).contains( + node.attributes[kSmartEditInstructionType], + ) && + node.attributes[kSmartEditInputType] is String; + }; + + @override + Widget build(NodeWidgetContext 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? 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 _onReplace() async { + final selection = widget.editorState.service.selectionService + .currentSelection.value?.normalized; + final selectedNodes = widget + .editorState.service.selectionService.currentSelectedNodes.normalized + .whereType(); + 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 _onExit() async { + final transaction = widget.editorState.transaction; + transaction.deleteNode(widget.node); + return widget.editorState.apply( + transaction, + options: const ApplyOptions( + recordRedo: false, + recordUndo: false, + ), + ); + } + + Future> _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(), + ), + ); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart new file mode 100644 index 0000000000..8ec609ef8f --- /dev/null +++ b/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().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( + 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() + .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, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart index 8eb2664ca1..35e786ff38 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/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() .add(SettingsUserEvent.updateUserOpenAIKey(val)); diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/appflowy_editor.dart index 2f48b2cfad..bea819e232 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/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'; diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart index 71a6aa01de..e383cb1d1d 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart +++ b/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 textNodes, + Selection selection, + ) { + List 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'); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/text/text_commands.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/text/text_commands.dart index f8e0db5916..3a6c62aa62 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/text/text_commands.dart +++ b/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]. diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart index 81d821d19e..a0bfe1cb0c 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart +++ b/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 textNodes, + Selection selection, + List 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, + ); + } + } + } + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/extensions/node_extensions.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/extensions/node_extensions.dart index 877a97fb57..0a89ecc4ef 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/extensions/node_extensions.dart +++ b/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 on List { + List get normalized { + if (isEmpty) { + return this; + } + + if (first.path > last.path) { + return reversed.toList(); + } + + return this; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart index 4c30f1f9d3..844ab4b2b6 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart +++ b/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( diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart index 4b6170620b..85b1597564 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart +++ b/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(); } } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart index 2a03d96140..93be8b0240 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart @@ -66,14 +66,19 @@ class _ToolbarWidgetState extends State 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), diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/toolbar_service.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/toolbar_service.dart index 06343b3740..762d550803 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/toolbar_service.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/toolbar_service.dart @@ -78,7 +78,7 @@ class _FlowyToolbarState extends State 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; } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/test/command/command_extension_test.dart b/frontend/appflowy_flutter/packages/appflowy_editor/test/command/command_extension_test.dart new file mode 100644 index 0000000000..1c7325987b --- /dev/null +++ b/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() + .toList(growable: false); + final text = editor.editorState.getTextInSelection( + textNodes.normalized, + selection.normalized, + ); + expect(text, 'me\nto\nAppfl'); + }); + }); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/test/extensions/node_extension_test.dart b/frontend/appflowy_flutter/packages/appflowy_editor/test/extensions/node_extension_test.dart index 70b18f22a7..3c8b3b0cc0 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/test/extensions/node_extension_test.dart +++ b/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]]', + ); + }); }); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index c15cab519f..8e878cb24e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/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(