From 682e3dce532ef6c7515c525759d0ff40c4f39be4 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 9 Mar 2023 14:38:54 +0700 Subject: [PATCH] chore: pre-release 0.1.1 (#3) --- .github/workflows/commit_lint.yml | 12 + .github/workflows/test.yml | 40 +++ CHANGELOG.md | 6 + README.md | 24 +- documentation/customizing.md | 18 +- documentation/importing.md | 2 +- documentation/testing.md | 2 +- documentation/translation.md | 4 +- example/lib/home_page.dart | 2 +- example/lib/pages/simple_editor.dart | 14 - example/lib/plugin/AI/auto_completion.dart | 2 +- example/lib/plugin/AI/continue_to_write.dart | 2 +- .../flutter/generated_plugin_registrant.cc | 19 -- .../flutter/generated_plugin_registrant.h | 15 - example/linux/flutter/generated_plugins.cmake | 25 -- .../Flutter/GeneratedPluginRegistrant.swift | 16 - example/macos/Podfile | 2 +- example/macos/Podfile.lock | 8 +- .../macos/Runner.xcodeproj/project.pbxproj | 9 +- example/pubspec.yaml | 2 +- .../flutter/generated_plugin_registrant.cc | 14 - .../flutter/generated_plugin_registrant.h | 15 - .../windows/flutter/generated_plugins.cmake | 24 -- lib/appflowy_editor.dart | 5 +- lib/src/commands/command_extension.dart | 25 ++ lib/src/commands/text/text_commands.dart | 1 - .../core/legacy/built_in_attribute_keys.dart | 1 - lib/src/core/location/position.dart | 9 + lib/src/core/location/selection.dart | 7 + lib/src/core/transform/transaction.dart | 51 ++++ lib/src/editor_state.dart | 4 +- lib/src/extensions/node_extensions.dart | 14 + .../plugins/markdown/document_markdown.dart | 41 ++- .../encoder/document_markdown_encoder.dart | 9 +- .../encoder/parser/divider_node_parser.dart | 14 - lib/src/render/action_menu/action_menu.dart | 180 ++++++++++++ .../render/action_menu/action_menu_item.dart | 111 +++++++ lib/src/render/image/image_node_builder.dart | 74 ++++- lib/src/render/image/image_node_widget.dart | 145 +-------- lib/src/render/image/image_upload_widget.dart | 2 +- lib/src/render/rich_text/flowy_rich_text.dart | 9 +- .../selection_menu_item_widget.dart | 2 +- .../selection_menu_service.dart | 20 +- .../selection_menu/selection_menu_widget.dart | 17 +- lib/src/render/style/editor_style.dart | 8 +- lib/src/render/toolbar/toolbar_item.dart | 25 +- .../render/toolbar/toolbar_item_widget.dart | 37 +-- lib/src/render/toolbar/toolbar_widget.dart | 21 +- lib/src/service/editor_service.dart | 17 +- lib/src/service/input_service.dart | 11 + .../arrow_keys_handler.dart | 27 ++ .../backspace_handler.dart | 9 +- .../checkbox_event_handler.dart | 38 +++ .../copy_paste_handler.dart | 1 - .../markdown_syntax_to_styled_text.dart | 187 ++++++------ .../tab_handler.dart | 6 +- .../whitespace_handler.dart | 4 +- lib/src/service/keyboard_service.dart | 12 +- lib/src/service/render_plugin_service.dart | 34 ++- lib/src/service/selection_service.dart | 52 +++- .../built_in_shortcut_events.dart | 45 ++- lib/src/service/toolbar_service.dart | 14 +- pubspec.yaml | 8 +- test/command/command_extension_test.dart | 36 +++ test/core/document/attributes_test.dart | 31 ++ test/core/document/node_iterator_test.dart | 1 - test/core/document/node_test.dart | 18 ++ test/core/document/path_test.dart | 38 +++ test/extensions/node_extension_test.dart | 34 ++- test/infra/test_editor.dart | 37 ++- test/infra/test_raw_key_event.dart | 11 +- .../document_markdown_encoder_test.dart | 5 +- .../parser/divider_node_parser_test.dart | 15 - test/render/action_menu/action_menu_test.dart | 165 +++++++++++ .../render/image/image_node_builder_test.dart | 36 +-- test/render/image/image_node_widget_test.dart | 55 +--- test/render/rich_text/checkbox_text_test.dart | 2 +- .../rich_text/toolbar_rich_text_test.dart | 12 + .../selection_menu_widget_test.dart | 91 +++--- .../arrow_keys_handler_test.dart | 127 ++++++++ .../checkbox_event_handler_test.dart | 241 +++++++++++++++ .../format_style_handler_test.dart | 1 + ...wn_syntax_to_styled_text_handler_test.dart | 277 ------------------ .../markdown_syntax_to_styled_text_test.dart | 188 ++++++++++++ .../slash_handler_test.dart | 2 +- .../tab_handler_test.dart | 117 ++++++++ test/service/selection_service_test.dart | 1 + test/service/toolbar_service_test.dart | 8 + 88 files changed, 2142 insertions(+), 981 deletions(-) create mode 100644 .github/workflows/commit_lint.yml create mode 100644 .github/workflows/test.yml delete mode 100644 example/linux/flutter/generated_plugin_registrant.cc delete mode 100644 example/linux/flutter/generated_plugin_registrant.h delete mode 100644 example/linux/flutter/generated_plugins.cmake delete mode 100644 example/macos/Flutter/GeneratedPluginRegistrant.swift delete mode 100644 example/windows/flutter/generated_plugin_registrant.cc delete mode 100644 example/windows/flutter/generated_plugin_registrant.h delete mode 100644 example/windows/flutter/generated_plugins.cmake delete mode 100644 lib/src/plugins/markdown/encoder/parser/divider_node_parser.dart create mode 100644 lib/src/render/action_menu/action_menu.dart create mode 100644 lib/src/render/action_menu/action_menu_item.dart create mode 100644 lib/src/service/internal_key_event_handlers/checkbox_event_handler.dart create mode 100644 test/command/command_extension_test.dart delete mode 100644 test/plugins/markdown/encoder/parser/divider_node_parser_test.dart create mode 100644 test/render/action_menu/action_menu_test.dart create mode 100644 test/service/internal_key_event_handlers/checkbox_event_handler_test.dart delete mode 100644 test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart diff --git a/.github/workflows/commit_lint.yml b/.github/workflows/commit_lint.yml new file mode 100644 index 000000000..4c9a5a547 --- /dev/null +++ b/.github/workflows/commit_lint.yml @@ -0,0 +1,12 @@ +name: Commit messages lint +on: [pull_request, push] + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v4 + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..706d9ac70 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: AppFlowyEditor test + +on: + pull_request: + branches: + - "main" + - "release/*" + +env: + FLUTTER_VERSION: "3.7.5" + +jobs: + tests: + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Run tests + run: | + flutter pub get + flutter analyze . + dart format --set-exit-if-changed . + flutter test --coverage + + - uses: codecov/codecov-action@v3 + with: + env_vars: ${{ matrix.os }} + fail_ci_if_error: true + verbose: true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e12ebf99..2bb252a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.1.1 +* Support Flutter 3.7.5 + +## 0.1.0 +* Support Flutter 3.7.5 + ## 0.0.9 * Support customize the text color and text background color. * Fix some bugs. diff --git a/README.md b/README.md index c32695f31..2975c839d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ and the Flutter guide for

- +
## Key Features @@ -38,7 +38,7 @@ and the Flutter guide for * shortcut events * themes * menu options (**coming soon!**) -* [Test-coverage](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md) and ongoing maintenance by AppFlowy's core team and community of more than 1,000 builders +* [Test-coverage](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/testing.md) and ongoing maintenance by AppFlowy's core team and community of more than 1,000 builders ## Getting Started @@ -60,7 +60,7 @@ final editor = AppFlowyEditor( ); ``` -You can also create an editor from a JSON object in order to configure your initial state. Or you can [create an editor from Markdown or Quill Delta](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/importing.md). +You can also create an editor from a JSON object in order to configure your initial state. Or you can [create an editor from Markdown or Quill Delta](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/importing.md). ```dart final json = ...; @@ -91,23 +91,23 @@ flutter run ### Customizing Components -Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing components](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md#customize-a-component). +Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing components](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/customizing.md#customize-a-component). Below are some examples of component customizations: - * [Checkbox Text](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart) demonstrates how to extend new styles based on existing rich text components - * [Image](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart) demonstrates how to extend a new node and render it - * See further examples of [rich-text plugins](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text) + * [Checkbox Text](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/render/rich_text/checkbox_text.dart) demonstrates how to extend new styles based on existing rich text components + * [Image](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/example/lib/plugin/network_image_node_widget.dart) demonstrates how to extend a new node and render it + * See further examples of [rich-text plugins](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/render/rich_text) ### Customizing Shortcut Events -Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing shortcut events](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md#customize-a-shortcut-event). +Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing shortcut events](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/customizing.md#customize-a-shortcut-event). Below are some examples of shortcut event customizations: - * [BIUS](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart) demonstrates how to make text bold/italic/underline/strikethrough through shortcut keys - * [Paste HTML](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart) gives you an idea on how to handle pasted styles through shortcut keys - * Need more examples? Check out [Internal key event handlers](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers) + * [BIUS](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/service/internal_key_event_handlers/format_style_handler.dart) demonstrates how to make text bold/italic/underline/strikethrough through shortcut keys + * [Paste HTML](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart) gives you an idea on how to handle pasted styles through shortcut keys + * Need more examples? Check out [Internal key event handlers](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/service/internal_key_event_handlers) ## Glossary Please refer to the API documentation. @@ -120,6 +120,6 @@ Please look at [CONTRIBUTING.md](https://appflowy.gitbook.io/docs/essential-docu ## License All code contributed to the AppFlowy Editor project is dual-licensed, and released under both of the following licenses: 1. The GNU Affero General Public License Version 3 -2. The Mozilla Public License, Version 2.0 (the “MPL”) +2. The Mozilla Public License, Version 2.0 (the “MPL”) See [LICENSE](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/LICENSE) for more information. diff --git a/documentation/customizing.md b/documentation/customizing.md index 2733a7947..a7c6f744a 100644 --- a/documentation/customizing.md +++ b/documentation/customizing.md @@ -30,7 +30,7 @@ At this point, nothing magic will happen after typing `_xxx_`. To implement our shortcut event we will create a `ShortcutEvent` instance to handle an underscore input. -We need to define `key` and `command` in a ShortCutEvent object to customize hotkeys. We recommend using the description of your event as a key. For example, if the underscore `_` is defined to make text italic, the key can be 'Underscore to italic'. +We need to define `key` and `command` in a ShortCutEvent object to customize hotkeys. We recommend using the description of your event as a key. For example, if the underscore `_` is defined to make text italic, the key can be 'Underscore to italic'. > The command, made up of a single keyword such as `underscore` or a combination of keywords using the `+` sign in between to concatenate, is a condition that triggers a user-defined function. To see which keywords are available to define a command, please refer to [key_mapping.dart](../lib/src/service/shortcut_event/key_mapping.dart). > If more than one commands trigger the same handler, then we use ',' to split them. For example, using CTRL and A or CMD and A to 'select all', we describe it as `cmd+a,ctrl+a`(case-insensitive). @@ -67,10 +67,10 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) { } ``` -Now, we deal with handling the underscore. +Now, we deal with handling the underscore. -Look for the position of the previous underscore and -1. if one is _not_ found, return without doing anything. +Look for the position of the previous underscore and +1. if one is _not_ found, return without doing anything. 2. if one is found, the text enclosed within the two underscores will be formatted to display in italics. ```dart @@ -136,7 +136,7 @@ Widget build(BuildContext context) { ![After](./images/customize_a_shortcut_event_after.gif) -Check out the [complete code](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart) file of this example. +Check out the [complete code](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart) file of this example. ## Customizing a Component @@ -162,7 +162,7 @@ Widget build(BuildContext context) { } ``` -Next, we will choose a unique string for your custom node's type. +Next, we will choose a unique string for your custom node's type. We'll use `network_image` in this case. And we add `network_image_src` to the `attributes` to describe the link of the image. @@ -176,7 +176,7 @@ We'll use `network_image` in this case. And we add `network_image_src` to the `a ``` Then, we create a class that inherits [NodeWidgetBuilder](../lib/src/service/render_plugin_service.dart). As shown in the autoprompt, we need to implement two functions: -1. one returns a widget +1. one returns a widget 2. the other verifies the correctness of the [Node](../lib/src/core/document/node.dart). @@ -273,7 +273,7 @@ class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { ``` ... and register `NetworkImageNodeWidgetBuilder` in the `AppFlowyEditor`. - + ```dart final editorState = EditorState( document: StateTree.empty() @@ -302,7 +302,7 @@ return AppFlowyEditor( ![Whew!](./images/customize_a_component.gif) -Check out the [complete code](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart) file of this example. +Check out the [complete code](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/example/lib/plugin/network_image_node_widget.dart) file of this example. ## Customizing a Theme (New Feature in 0.0.7) diff --git a/documentation/importing.md b/documentation/importing.md index abe682ade..7c8acca72 100644 --- a/documentation/importing.md +++ b/documentation/importing.md @@ -33,4 +33,4 @@ final editorState = EditorState( ); ``` -For more details, please refer to the function `_importFile` through this [link](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart). \ No newline at end of file +For more details, please refer to the function `_importFile` through this [link](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/example/lib/home_page.dart). \ No newline at end of file diff --git a/documentation/testing.md b/documentation/testing.md index c72151084..035e960e7 100644 --- a/documentation/testing.md +++ b/documentation/testing.md @@ -58,7 +58,7 @@ Get the node of a defined path. In this case we are getting the first node of th final firstTextNode = editor.nodeAtPath([0]) as TextNode; ``` -Update the [Selection](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart) so that our text "Welcome to Appflowy 😁" is selected. We will start our selection from the beginning of the string. +Update the [Selection](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/document/selection.dart) so that our text "Welcome to Appflowy 😁" is selected. We will start our selection from the beginning of the string. ```dart await editor.updateSelection( diff --git a/documentation/translation.md b/documentation/translation.md index 54c5900bc..78ad41cec 100644 --- a/documentation/translation.md +++ b/documentation/translation.md @@ -3,13 +3,13 @@ You can help Appflowy Editor in supporting various languages by contributing. Follow the steps below sequentially to contribute translations. ## Steps to modify an existing translation -Translation files are located in: `frontend/app_flowy/packages/appflowy_editor/lib/l10n/` +Translation files are located in: `lib/l10n/` 1. Install the Visual Studio Code plugin: [Flutter intl](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) 2. Modify the specific translation file. 3. Save the file and the translation will be generated automatically. ## Steps to add new language -Translation files are located in: `frontend/app_flowy/packages/appflowy_editor/lib/l10n/` +Translation files are located in: `lib/l10n/` 1. Install the Visual Studio Code plugin: [Flutter intl](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) 2. Copy the `intl_en.arb` as a base translation and rename the new file to `intl_.arb` 3. Modify the new translation file. diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index a20990f84..89a1c393a 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -315,7 +315,7 @@ Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC var jsonString = ''; switch (fileType) { case ExportFileType.json: - jsonString = jsonEncode(plainText); + jsonString = plainText; break; case ExportFileType.markdown: jsonString = jsonEncode(markdownToDocument(plainText).toJson()); diff --git a/example/lib/pages/simple_editor.dart b/example/lib/pages/simple_editor.dart index b98ec7230..8dfdf8058 100644 --- a/example/lib/pages/simple_editor.dart +++ b/example/lib/pages/simple_editor.dart @@ -1,10 +1,6 @@ import 'dart:convert'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:example/plugin/AI/continue_to_write.dart'; -import 'package:example/plugin/AI/auto_completion.dart'; -import 'package:example/plugin/AI/gpt3.dart'; -import 'package:example/plugin/AI/smart_edit.dart'; import 'package:flutter/material.dart'; class SimpleEditor extends StatelessWidget { @@ -42,16 +38,6 @@ class SimpleEditor extends StatelessWidget { editorState: editorState, themeData: themeData, autoFocus: editorState.document.isEmpty, - selectionMenuItems: [ - // Open AI - if (apiKey.isNotEmpty) ...[ - autoCompletionMenuItem, - continueToWriteMenuItem, - ] - ], - toolbarItems: [ - smartEditItem, - ], ); } else { return const Center( diff --git a/example/lib/plugin/AI/auto_completion.dart b/example/lib/plugin/AI/auto_completion.dart index c2e9447b6..e90f8bd3d 100644 --- a/example/lib/plugin/AI/auto_completion.dart +++ b/example/lib/plugin/AI/auto_completion.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; SelectionMenuItem autoCompletionMenuItem = SelectionMenuItem( - name: () => 'Auto generate content', + name: 'Auto generate content', icon: (editorState, onSelected) => Icon( Icons.rocket, size: 18.0, diff --git a/example/lib/plugin/AI/continue_to_write.dart b/example/lib/plugin/AI/continue_to_write.dart index e3e407d48..4e0b2ec10 100644 --- a/example/lib/plugin/AI/continue_to_write.dart +++ b/example/lib/plugin/AI/continue_to_write.dart @@ -4,7 +4,7 @@ import 'package:example/plugin/AI/text_robot.dart'; import 'package:flutter/material.dart'; SelectionMenuItem continueToWriteMenuItem = SelectionMenuItem( - name: () => 'Continue To Write', + name: 'Continue To Write', icon: (editorState, onSelected) => Icon( Icons.print, size: 18.0, diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 00fd3bc03..000000000 --- a/example/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) rich_clipboard_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "RichClipboardPlugin"); - rich_clipboard_plugin_register_with_registrar(rich_clipboard_linux_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47bc..000000000 --- a/example/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 0342e3868..000000000 --- a/example/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,25 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - rich_clipboard_linux - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 8e224cb06..000000000 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import path_provider_foundation -import rich_clipboard_macos -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/example/macos/Podfile b/example/macos/Podfile index dade8dfad..049abe295 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.11' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index ae16b346a..3e6dfa9c5 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -25,11 +25,11 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 - path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c - url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 + url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451 -PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c +PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 COCOAPODS: 1.11.3 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 057a1a822..954ca1e07 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -273,6 +273,7 @@ }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -404,7 +405,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -483,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -530,7 +531,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b84f5baf1..691c13d77 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -70,7 +70,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - example.json + - assets/example.json - assets/images/icon.png # An image asset can refer to one or more resolution-specific "variants", see diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 4f7884874..000000000 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); -} diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85a..000000000 --- a/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 88b22e5c7..000000000 --- a/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - url_launcher_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/lib/appflowy_editor.dart b/lib/appflowy_editor.dart index 93f58d1b5..89264f9c4 100644 --- a/lib/appflowy_editor.dart +++ b/lib/appflowy_editor.dart @@ -33,7 +33,6 @@ export 'src/render/rich_text/flowy_rich_text.dart'; export 'src/render/selection_menu/selection_menu_widget.dart'; export 'src/l10n/l10n.dart'; export 'src/render/style/plugin_styles.dart'; -export 'src/render/style/editor_style.dart'; export 'src/plugins/markdown/encoder/delta_markdown_encoder.dart'; export 'src/plugins/markdown/encoder/document_markdown_encoder.dart'; export 'src/plugins/markdown/encoder/parser/node_parser.dart'; @@ -43,5 +42,9 @@ 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'; +export 'src/render/action_menu/action_menu_item.dart'; +export 'src/core/document/node_iterator.dart'; diff --git a/lib/src/commands/command_extension.dart b/lib/src/commands/command_extension.dart index 71a6aa01d..5a9ea4f4b 100644 --- a/lib/src/commands/command_extension.dart +++ b/lib/src/commands/command_extension.dart @@ -51,4 +51,29 @@ 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++) { + final plainText = textNodes[i].toPlainText(); + if (i == 0) { + res.add( + plainText.substring( + selection.startIndex, + plainText.length, + ), + ); + } else if (i == textNodes.length - 1) { + res.add(plainText.substring(0, selection.endIndex)); + } else { + res.add(plainText); + } + } + } + return res.join('\n'); + } } diff --git a/lib/src/commands/text/text_commands.dart b/lib/src/commands/text/text_commands.dart index f8e0db591..3a6c62aa6 100644 --- a/lib/src/commands/text/text_commands.dart +++ b/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/lib/src/core/legacy/built_in_attribute_keys.dart b/lib/src/core/legacy/built_in_attribute_keys.dart index 3f334bdd0..6421e7efd 100644 --- a/lib/src/core/legacy/built_in_attribute_keys.dart +++ b/lib/src/core/legacy/built_in_attribute_keys.dart @@ -37,7 +37,6 @@ class BuiltInAttributeKey { static String checkbox = 'checkbox'; static String code = 'code'; static String number = 'number'; - static String defaultFormating = 'defaultFormating'; static List partialStyleKeys = [ BuiltInAttributeKey.bold, diff --git a/lib/src/core/location/position.dart b/lib/src/core/location/position.dart index e793faa62..4f3d104d2 100644 --- a/lib/src/core/location/position.dart +++ b/lib/src/core/location/position.dart @@ -9,6 +9,15 @@ class Position { this.offset = 0, }); + factory Position.fromJson(Map json) { + final path = Path.from(json['path'] as List); + final offset = json['offset']; + return Position( + path: path, + offset: offset ?? 0, + ); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/lib/src/core/location/selection.dart b/lib/src/core/location/selection.dart index b22d743c7..39410897e 100644 --- a/lib/src/core/location/selection.dart +++ b/lib/src/core/location/selection.dart @@ -15,6 +15,13 @@ class Selection { required this.end, }); + factory Selection.fromJson(Map json) { + return Selection( + start: Position.fromJson(json['start']), + end: Position.fromJson(json['end']), + ); + } + /// Create a selection with [Path], [startOffset] and [endOffset]. /// /// The [endOffset] is optional. diff --git a/lib/src/core/transform/transaction.dart b/lib/src/core/transform/transaction.dart index 81d821d19..a0bfe1cb0 100644 --- a/lib/src/core/transform/transaction.dart +++ b/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/lib/src/editor_state.dart b/lib/src/editor_state.dart index 00613fa16..44e4e3d5f 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -145,7 +145,7 @@ class EditorState { completer.complete(); return completer.future; } - // TODO: validate the transation. + // TODO: validate the transaction. for (final op in transaction.operations) { _applyOperation(op); } @@ -156,8 +156,8 @@ class EditorState { _applyRules(ruleCount); if (withUpdateCursor) { await updateCursorSelection(transaction.afterSelection); - completer.complete(); } + completer.complete(); }); if (options.recordUndo) { diff --git a/lib/src/extensions/node_extensions.dart b/lib/src/extensions/node_extensions.dart index 877a97fb5..0a89ecc4e 100644 --- a/lib/src/extensions/node_extensions.dart +++ b/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/lib/src/plugins/markdown/document_markdown.dart b/lib/src/plugins/markdown/document_markdown.dart index 224f9b6cc..b47c17fe7 100644 --- a/lib/src/plugins/markdown/document_markdown.dart +++ b/lib/src/plugins/markdown/document_markdown.dart @@ -5,24 +5,45 @@ import 'dart:convert'; import 'package:appflowy_editor/src/core/document/document.dart'; import 'package:appflowy_editor/src/plugins/markdown/decoder/document_markdown_decoder.dart'; import 'package:appflowy_editor/src/plugins/markdown/encoder/document_markdown_encoder.dart'; - -/// Codec used to convert between Markdown and AppFlowy Editor Document. -const AppFlowyEditorMarkdownCodec _kCodec = AppFlowyEditorMarkdownCodec(); - -Document markdownToDocument(String markdown) { - return _kCodec.decode(markdown); +import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/image_node_parser.dart'; +import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart'; +import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/text_node_parser.dart'; + +/// Converts a markdown to [Document]. +/// +/// [customParsers] is a list of custom parsers that will be used to parse the markdown. +Document markdownToDocument( + String markdown, { + List customParsers = const [], +}) { + return const AppFlowyEditorMarkdownCodec().decode(markdown); } -String documentToMarkdown(Document document) { - return _kCodec.encode(document); +/// Converts a [Document] to markdown. +/// +/// [customParsers] is a list of custom parsers that will be used to parse the markdown. +String documentToMarkdown(Document document, + {List customParsers = const []}) { + return AppFlowyEditorMarkdownCodec(encodeParsers: [ + ...customParsers, + const TextNodeParser(), + const ImageNodeParser(), + ]).encode(document); } class AppFlowyEditorMarkdownCodec extends Codec { - const AppFlowyEditorMarkdownCodec(); + const AppFlowyEditorMarkdownCodec({ + this.encodeParsers = const [], + }); + + final List encodeParsers; + // TODO: Add support for custom parsers @override Converter get decoder => DocumentMarkdownDecoder(); @override - Converter get encoder => DocumentMarkdownEncoder(); + Converter get encoder => DocumentMarkdownEncoder( + parsers: encodeParsers, + ); } diff --git a/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart b/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart index 5a666bd44..1963a9f63 100644 --- a/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart +++ b/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart @@ -1,18 +1,11 @@ import 'dart:convert'; import 'package:appflowy_editor/src/core/document/document.dart'; -import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/divider_node_parser.dart'; -import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/image_node_parser.dart'; import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart'; -import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/text_node_parser.dart'; class DocumentMarkdownEncoder extends Converter { DocumentMarkdownEncoder({ - this.parsers = const [ - TextNodeParser(), - ImageNodeParser(), - DividerNodeParser(), - ], + this.parsers = const [], }); final List parsers; diff --git a/lib/src/plugins/markdown/encoder/parser/divider_node_parser.dart b/lib/src/plugins/markdown/encoder/parser/divider_node_parser.dart deleted file mode 100644 index c9742fbd6..000000000 --- a/lib/src/plugins/markdown/encoder/parser/divider_node_parser.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart'; - -class DividerNodeParser extends NodeParser { - const DividerNodeParser(); - - @override - String get id => 'divider'; - - @override - String transform(Node node) { - return '---\n'; - } -} diff --git a/lib/src/render/action_menu/action_menu.dart b/lib/src/render/action_menu/action_menu.dart new file mode 100644 index 000000000..1e242094f --- /dev/null +++ b/lib/src/render/action_menu/action_menu.dart @@ -0,0 +1,180 @@ +import 'package:appflowy_editor/src/core/document/node.dart'; +import 'package:appflowy_editor/src/core/document/path.dart'; +import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart'; +import 'package:appflowy_editor/src/render/style/editor_style.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// [ActionProvider] is an optional mixin to define the actions of a node widget. +mixin ActionProvider on NodeWidgetBuilder { + List actions(NodeWidgetContext context); +} + +class ActionMenuArenaMember { + final ActionMenuState state; + final VoidCallback listener; + + const ActionMenuArenaMember({required this.state, required this.listener}); +} + +/// Decides which action menu is visible. +/// The menu with the greatest [Node.path] wins. +class ActionMenuArena { + final Map _members = {}; + final Set _visible = {}; + + ActionMenuArena._singleton(); + static final instance = ActionMenuArena._singleton(); + + void add(ActionMenuState menuState) { + final member = ActionMenuArenaMember( + state: menuState, + listener: () { + final len = _visible.length; + if (menuState.isHover || menuState.isPinned) { + _visible.add(menuState.path); + } else { + _visible.remove(menuState.path); + } + if (len != _visible.length) { + _notifyAllVisible(); + } + }, + ); + menuState.addListener(member.listener); + _members[menuState.path] = member; + } + + void _notifyAllVisible() { + for (var path in _visible) { + _members[path]?.state.notify(); + } + } + + void remove(ActionMenuState menuState) { + final member = _members.remove(menuState.path); + if (member != null) { + menuState.removeListener(member.listener); + _visible.remove(menuState.path); + } + } + + bool isVisible(Path path) { + var sorted = _visible.toList() + ..sort( + (a, b) => a <= b ? 1 : -1, + ); + return sorted.isNotEmpty && path == sorted.first; + } +} + +/// Used to manage the state of each [ActionMenuOverlay]. +class ActionMenuState extends ChangeNotifier { + final Path path; + + ActionMenuState(this.path) { + ActionMenuArena.instance.add(this); + } + + @override + void dispose() { + ActionMenuArena.instance.remove(this); + super.dispose(); + } + + bool _isHover = false; + bool _isPinned = false; + + bool get isPinned => _isPinned; + bool get isHover => _isHover; + bool get isVisible => ActionMenuArena.instance.isVisible(path); + + set isPinned(bool value) { + if (_isPinned == value) { + return; + } + _isPinned = value; + notifyListeners(); + } + + set isHover(bool value) { + if (_isHover == value) { + return; + } + _isHover = value; + notifyListeners(); + } + + void notify() { + notifyListeners(); + } +} + +/// The default widget to render an action menu +class ActionMenuWidget extends StatelessWidget { + final List items; + + const ActionMenuWidget({super.key, required this.items}); + + @override + Widget build(BuildContext context) { + final editorStyle = EditorStyle.of(context); + + return Card( + color: editorStyle?.selectionMenuBackgroundColor, + elevation: 3.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: items.map((item) { + return ActionMenuItemWidget( + item: item, + ); + }).toList(), + ), + ); + } +} + +class ActionMenuOverlay extends StatelessWidget { + final Widget child; + final List items; + final Positioned Function(BuildContext context, List items)? + customActionMenuBuilder; + + const ActionMenuOverlay({ + super.key, + required this.items, + required this.child, + this.customActionMenuBuilder, + }); + + @override + Widget build(BuildContext context) { + final menuState = Provider.of(context); + + return MouseRegion( + onEnter: (_) { + menuState.isHover = true; + }, + onExit: (_) { + menuState.isHover = false; + }, + onHover: (_) { + menuState.isHover = true; + }, + child: Stack( + children: [ + child, + if (menuState.isVisible) _buildMenu(context), + ], + ), + ); + } + + Positioned _buildMenu(BuildContext context) { + return customActionMenuBuilder != null + ? customActionMenuBuilder!(context, items) + : Positioned(top: 5, right: 5, child: ActionMenuWidget(items: items)); + } +} diff --git a/lib/src/render/action_menu/action_menu_item.dart b/lib/src/render/action_menu/action_menu_item.dart new file mode 100644 index 000000000..5129b8314 --- /dev/null +++ b/lib/src/render/action_menu/action_menu_item.dart @@ -0,0 +1,111 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:flutter/material.dart'; + +/// Represents a single action inside an action menu. +/// +/// [itemWrapper] can be used to wrap the [ActionMenuItemWidget] with another +/// widget (e.g. a popover). +class ActionMenuItem { + final Widget Function({double? size, Color? color}) iconBuilder; + final Function()? onPressed; + final bool Function()? selected; + final Widget Function(Widget item)? itemWrapper; + + ActionMenuItem({ + required this.iconBuilder, + required this.onPressed, + this.selected, + this.itemWrapper, + }); + + factory ActionMenuItem.icon({ + required IconData iconData, + required Function()? onPressed, + bool Function()? selected, + Widget Function(Widget item)? itemWrapper, + }) { + return ActionMenuItem( + iconBuilder: ({size, color}) { + return Icon( + iconData, + size: size, + color: color, + ); + }, + onPressed: onPressed, + selected: selected, + itemWrapper: itemWrapper, + ); + } + + factory ActionMenuItem.svg({ + required String name, + required Function()? onPressed, + bool Function()? selected, + Widget Function(Widget item)? itemWrapper, + }) { + return ActionMenuItem( + iconBuilder: ({size, color}) { + return FlowySvg( + name: name, + color: color, + width: size, + height: size, + ); + }, + onPressed: onPressed, + selected: selected, + itemWrapper: itemWrapper, + ); + } + + factory ActionMenuItem.separator() { + return ActionMenuItem( + iconBuilder: ({size, color}) { + return FlowySvg( + name: 'image_toolbar/divider', + color: color, + height: size, + ); + }, + onPressed: null, + ); + } +} + +class ActionMenuItemWidget extends StatelessWidget { + final ActionMenuItem item; + final double iconSize; + + const ActionMenuItemWidget({ + super.key, + required this.item, + this.iconSize = 20, + }); + + @override + Widget build(BuildContext context) { + final editorStyle = EditorStyle.of(context); + final isSelected = item.selected?.call() ?? false; + final color = isSelected + ? editorStyle?.selectionMenuItemSelectedIconColor + : editorStyle?.selectionMenuItemIconColor; + + var icon = item.iconBuilder(size: iconSize, color: color); + var itemWidget = Padding( + padding: const EdgeInsets.all(3), + child: item.onPressed != null + ? MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: item.onPressed, + child: icon, + ), + ) + : icon, + ); + + return item.itemWrapper?.call(itemWidget) ?? itemWidget; + } +} diff --git a/lib/src/render/image/image_node_builder.dart b/lib/src/render/image/image_node_builder.dart index 56115af39..4f5e760ea 100644 --- a/lib/src/render/image/image_node_builder.dart +++ b/lib/src/render/image/image_node_builder.dart @@ -1,11 +1,14 @@ import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/src/infra/clipboard.dart'; +import 'package:appflowy_editor/src/render/action_menu/action_menu.dart'; +import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart'; import 'package:appflowy_editor/src/service/render_plugin_service.dart'; import 'package:flutter/material.dart'; import 'image_node_widget.dart'; -class ImageNodeBuilder extends NodeWidgetBuilder { +class ImageNodeBuilder extends NodeWidgetBuilder + with ActionProvider { @override Widget build(NodeWidgetContext context) { final src = context.node.attributes['image_src']; @@ -20,21 +23,6 @@ class ImageNodeBuilder extends NodeWidgetBuilder { src: src, width: width, alignment: _textToAlignment(align), - onCopy: () { - AppFlowyClipboard.setData(text: src); - }, - onDelete: () { - final transaction = context.editorState.transaction - ..deleteNode(context.node); - context.editorState.apply(transaction); - }, - onAlign: (alignment) { - final transaction = context.editorState.transaction - ..updateNode(context.node, { - 'align': _alignmentToText(alignment), - }); - context.editorState.apply(transaction); - }, onResize: (width) { final transaction = context.editorState.transaction ..updateNode(context.node, { @@ -52,6 +40,52 @@ class ImageNodeBuilder extends NodeWidgetBuilder { node.attributes.containsKey('align'); }); + @override + List actions(NodeWidgetContext context) { + return [ + ActionMenuItem.svg( + name: 'image_toolbar/align_left', + selected: () { + final align = context.node.attributes['align']; + return _textToAlignment(align) == Alignment.centerLeft; + }, + onPressed: () => _onAlign(context, Alignment.centerLeft), + ), + ActionMenuItem.svg( + name: 'image_toolbar/align_center', + selected: () { + final align = context.node.attributes['align']; + return _textToAlignment(align) == Alignment.center; + }, + onPressed: () => _onAlign(context, Alignment.center), + ), + ActionMenuItem.svg( + name: 'image_toolbar/align_right', + selected: () { + final align = context.node.attributes['align']; + return _textToAlignment(align) == Alignment.centerRight; + }, + onPressed: () => _onAlign(context, Alignment.centerRight), + ), + ActionMenuItem.separator(), + ActionMenuItem.svg( + name: 'image_toolbar/copy', + onPressed: () { + final src = context.node.attributes['image_src']; + AppFlowyClipboard.setData(text: src); + }, + ), + ActionMenuItem.svg( + name: 'image_toolbar/delete', + onPressed: () { + final transaction = context.editorState.transaction + ..deleteNode(context.node); + context.editorState.apply(transaction); + }, + ), + ]; + } + Alignment _textToAlignment(String text) { if (text == 'left') { return Alignment.centerLeft; @@ -69,4 +103,12 @@ class ImageNodeBuilder extends NodeWidgetBuilder { } return 'center'; } + + void _onAlign(NodeWidgetContext context, Alignment alignment) { + final transaction = context.editorState.transaction + ..updateNode(context.node, { + 'align': _alignmentToText(alignment), + }); + context.editorState.apply(transaction); + } } diff --git a/lib/src/render/image/image_node_widget.dart b/lib/src/render/image/image_node_widget.dart index 1a0d63862..27812d996 100644 --- a/lib/src/render/image/image_node_widget.dart +++ b/lib/src/render/image/image_node_widget.dart @@ -1,8 +1,7 @@ -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/src/core/location/position.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/extensions/object_extensions.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:flutter/material.dart'; @@ -13,9 +12,6 @@ class ImageNodeWidget extends StatefulWidget { required this.src, this.width, required this.alignment, - required this.onCopy, - required this.onDelete, - required this.onAlign, required this.onResize, }) : super(key: key); @@ -23,9 +19,6 @@ class ImageNodeWidget extends StatefulWidget { final String src; final double? width; final Alignment alignment; - final VoidCallback onCopy; - final VoidCallback onDelete; - final void Function(Alignment alignment) onAlign; final void Function(double width) onResize; @override @@ -146,8 +139,12 @@ class _ImageNodeWidgetState extends State widget.src, width: _imageWidth == null ? null : _imageWidth! - _distance, gaplessPlayback: true, - loadingBuilder: (context, child, loadingProgress) => - loadingProgress == null ? child : _buildLoading(context), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null || + loadingProgress.cumulativeBytesLoaded == + loadingProgress.expectedTotalBytes) return child; + return _buildLoading(context); + }, errorBuilder: (context, error, stackTrace) { // _imageWidth ??= defaultMaxTextNodeWidth; return _buildError(context); @@ -184,16 +181,6 @@ class _ImageNodeWidgetState extends State }); }, ), - if (_onFocus) - ImageToolbar( - top: 8, - right: 8, - height: 30, - alignment: widget.alignment, - onAlign: widget.onAlign, - onCopy: widget.onCopy, - onDelete: widget.onDelete, - ) ], ); } @@ -282,121 +269,3 @@ class _ImageNodeWidgetState extends State ); } } - -@visibleForTesting -class ImageToolbar extends StatelessWidget { - const ImageToolbar({ - Key? key, - required this.top, - required this.right, - required this.height, - required this.alignment, - required this.onCopy, - required this.onDelete, - required this.onAlign, - }) : super(key: key); - - final double top; - final double right; - final double height; - final Alignment alignment; - final VoidCallback onCopy; - final VoidCallback onDelete; - final void Function(Alignment alignment) onAlign; - - @override - Widget build(BuildContext context) { - return Positioned( - top: top, - right: right, - height: height, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFF333333), - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(8.0), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0), - icon: FlowySvg( - name: 'image_toolbar/align_left', - color: alignment == Alignment.centerLeft - ? const Color(0xFF00BCF0) - : null, - ), - onPressed: () { - onAlign(Alignment.centerLeft); - }, - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0), - icon: FlowySvg( - name: 'image_toolbar/align_center', - color: alignment == Alignment.center - ? const Color(0xFF00BCF0) - : null, - ), - onPressed: () { - onAlign(Alignment.center); - }, - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0), - icon: FlowySvg( - name: 'image_toolbar/align_right', - color: alignment == Alignment.centerRight - ? const Color(0xFF00BCF0) - : null, - ), - onPressed: () { - onAlign(Alignment.centerRight); - }, - ), - const Center( - child: FlowySvg( - name: 'image_toolbar/divider', - ), - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0), - icon: const FlowySvg( - name: 'image_toolbar/copy', - ), - onPressed: () { - onCopy(); - }, - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0), - icon: const FlowySvg( - name: 'image_toolbar/delete', - ), - onPressed: () { - onDelete(); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/render/image/image_upload_widget.dart b/lib/src/render/image/image_upload_widget.dart index a8909d16c..ac5f448bc 100644 --- a/lib/src/render/image/image_upload_widget.dart +++ b/lib/src/render/image/image_upload_widget.dart @@ -35,7 +35,7 @@ void showImageUploadMenu( ); }); - Overlay.of(context)?.insert(_imageUploadMenu!); + Overlay.of(context).insert(_imageUploadMenu!); editorState.service.selectionService.currentSelection .addListener(_dismissImageUploadMenu); diff --git a/lib/src/render/rich_text/flowy_rich_text.dart b/lib/src/render/rich_text/flowy_rich_text.dart index 0ac4092eb..2c92b43a3 100644 --- a/lib/src/render/rich_text/flowy_rich_text.dart +++ b/lib/src/render/rich_text/flowy_rich_text.dart @@ -95,11 +95,18 @@ class _FlowyRichTextState extends State with SelectableMixin { textPosition, Rect.zero) ?? Offset.zero; } + if (widget.cursorHeight != null && cursorHeight != null) { + cursorOffset = Offset( + cursorOffset.dx, + cursorOffset.dy + (cursorHeight - widget.cursorHeight!) / 2, + ); + cursorHeight = widget.cursorHeight; + } final rect = Rect.fromLTWH( cursorOffset.dx - (widget.cursorWidth / 2.0), cursorOffset.dy, widget.cursorWidth, - widget.cursorHeight ?? cursorHeight ?? 16.0, + cursorHeight ?? 16.0, ); return rect; } diff --git a/lib/src/render/selection_menu/selection_menu_item_widget.dart b/lib/src/render/selection_menu/selection_menu_item_widget.dart index 912d9447f..1d3a73f93 100644 --- a/lib/src/render/selection_menu/selection_menu_item_widget.dart +++ b/lib/src/render/selection_menu/selection_menu_item_widget.dart @@ -47,7 +47,7 @@ class _SelectionMenuItemWidgetState extends State { : MaterialStateProperty.all(Colors.transparent), ), label: Text( - widget.item.name(), + widget.item.name, textAlign: TextAlign.left, style: TextStyle( color: (widget.isSelected || _onHover) diff --git a/lib/src/render/selection_menu/selection_menu_service.dart b/lib/src/render/selection_menu/selection_menu_service.dart index 498438e6b..b66d03438 100644 --- a/lib/src/render/selection_menu/selection_menu_service.dart +++ b/lib/src/render/selection_menu/selection_menu_service.dart @@ -109,7 +109,7 @@ class SelectionMenu implements SelectionMenuService { ); }); - Overlay.of(context)?.insert(_selectionMenuEntry!); + Overlay.of(context).insert(_selectionMenuEntry!); editorState.service.keyboardService?.disable(showCursor: true); editorState.service.scrollService?.disable(); @@ -156,7 +156,7 @@ List get defaultSelectionMenuItems => _defaultSelectionMenuItems; final List _defaultSelectionMenuItems = [ SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.text, + name: AppFlowyEditorLocalizations.current.text, icon: (editorState, onSelected) => _selectionMenuIcon('text', editorState, onSelected), keywords: ['text'], @@ -165,7 +165,7 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.heading1, + name: AppFlowyEditorLocalizations.current.heading1, icon: (editorState, onSelected) => _selectionMenuIcon('h1', editorState, onSelected), keywords: ['heading 1, h1'], @@ -174,7 +174,7 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.heading2, + name: AppFlowyEditorLocalizations.current.heading2, icon: (editorState, onSelected) => _selectionMenuIcon('h2', editorState, onSelected), keywords: ['heading 2, h2'], @@ -183,7 +183,7 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.heading3, + name: AppFlowyEditorLocalizations.current.heading3, icon: (editorState, onSelected) => _selectionMenuIcon('h3', editorState, onSelected), keywords: ['heading 3, h3'], @@ -192,14 +192,14 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.image, + name: AppFlowyEditorLocalizations.current.image, icon: (editorState, onSelected) => _selectionMenuIcon('image', editorState, onSelected), keywords: ['image'], handler: showImageUploadMenu, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.bulletedList, + name: AppFlowyEditorLocalizations.current.bulletedList, icon: (editorState, onSelected) => _selectionMenuIcon('bulleted_list', editorState, onSelected), keywords: ['bulleted list', 'list', 'unordered list'], @@ -208,7 +208,7 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.numberedList, + name: AppFlowyEditorLocalizations.current.numberedList, icon: (editorState, onSelected) => _selectionMenuIcon('number', editorState, onSelected), keywords: ['numbered list', 'list', 'ordered list'], @@ -217,7 +217,7 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.checkbox, + name: AppFlowyEditorLocalizations.current.checkbox, icon: (editorState, onSelected) => _selectionMenuIcon('checkbox', editorState, onSelected), keywords: ['todo list', 'list', 'checkbox list'], @@ -226,7 +226,7 @@ final List _defaultSelectionMenuItems = [ }, ), SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.quote, + name: AppFlowyEditorLocalizations.current.quote, icon: (editorState, onSelected) => _selectionMenuIcon('quote', editorState, onSelected), keywords: ['quote', 'refer'], diff --git a/lib/src/render/selection_menu/selection_menu_widget.dart b/lib/src/render/selection_menu/selection_menu_widget.dart index 0d96853d7..3a88cfcd4 100644 --- a/lib/src/render/selection_menu/selection_menu_widget.dart +++ b/lib/src/render/selection_menu/selection_menu_widget.dart @@ -20,14 +20,14 @@ class SelectionMenuItem { required SelectionMenuItemHandler handler, }) { this.handler = (editorState, menuService, context) { - _deleteToSlash(editorState); + _deleteSlash(editorState); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { handler(editorState, menuService, context); }); }; } - final String Function() name; + final String name; final Widget Function(EditorState editorState, bool onSelected) icon; /// Customizes keywords for item. @@ -36,20 +36,23 @@ class SelectionMenuItem { final List keywords; late final SelectionMenuItemHandler handler; - void _deleteToSlash(EditorState editorState) { + void _deleteSlash(EditorState editorState) { final selectionService = editorState.service.selectionService; final selection = selectionService.currentSelection.value; final nodes = selectionService.currentSelectedNodes; if (selection != null && nodes.length == 1) { final node = nodes.first as TextNode; final end = selection.start.offset; - final start = node.toPlainText().substring(0, end).lastIndexOf('/'); + final lastSlashIndex = + node.toPlainText().substring(0, end).lastIndexOf('/'); + // delete all the texts after '/' along with '/' final transaction = editorState.transaction ..deleteText( node, - start, - selection.start.offset - start, + lastSlashIndex, + end - lastSlashIndex, ); + editorState.apply(transaction); } } @@ -81,7 +84,7 @@ class SelectionMenuItem { updateSelection, }) { return SelectionMenuItem( - name: () => name, + name: name, icon: (editorState, onSelected) => Icon( iconData, color: onSelected diff --git a/lib/src/render/style/editor_style.dart b/lib/src/render/style/editor_style.dart index 93305bb31..0d252d1e0 100644 --- a/lib/src/render/style/editor_style.dart +++ b/lib/src/render/style/editor_style.dart @@ -158,6 +158,10 @@ class EditorStyle extends ThemeExtension { ); } + static EditorStyle? of(BuildContext context) { + return Theme.of(context).extension(); + } + static final light = EditorStyle( padding: const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0), backgroundColor: Colors.white, @@ -166,8 +170,8 @@ class EditorStyle extends ThemeExtension { selectionMenuBackgroundColor: const Color(0xFFFFFFFF), selectionMenuItemTextColor: const Color(0xFF333333), selectionMenuItemIconColor: const Color(0xFF333333), - selectionMenuItemSelectedTextColor: const Color(0xFF333333), - selectionMenuItemSelectedIconColor: const Color(0xFF333333), + selectionMenuItemSelectedTextColor: const Color.fromARGB(255, 56, 91, 247), + selectionMenuItemSelectedIconColor: const Color.fromARGB(255, 56, 91, 247), selectionMenuItemSelectedColor: const Color(0xFFE0F8FF), textPadding: const EdgeInsets.symmetric(vertical: 8.0), textStyle: const TextStyle(fontSize: 16.0, color: Colors.black), diff --git a/lib/src/render/toolbar/toolbar_item.dart b/lib/src/render/toolbar/toolbar_item.dart index 4c30f1f9d..844ab4b2b 100644 --- a/lib/src/render/toolbar/toolbar_item.dart +++ b/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/lib/src/render/toolbar/toolbar_item_widget.dart b/lib/src/render/toolbar/toolbar_item_widget.dart index 4b6170620..85b159756 100644 --- a/lib/src/render/toolbar/toolbar_item_widget.dart +++ b/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/lib/src/render/toolbar/toolbar_widget.dart b/lib/src/render/toolbar/toolbar_widget.dart index 2a03d9614..93be8b024 100644 --- a/lib/src/render/toolbar/toolbar_widget.dart +++ b/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/lib/src/service/editor_service.dart b/lib/src/service/editor_service.dart index 1c15df05f..9fcfcfd10 100644 --- a/lib/src/service/editor_service.dart +++ b/lib/src/service/editor_service.dart @@ -1,16 +1,15 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/flutter/overlay.dart'; -import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; -import 'package:flutter/material.dart' hide Overlay, OverlayEntry; - import 'package:appflowy_editor/src/render/editor/editor_entry.dart'; +import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; import 'package:appflowy_editor/src/render/rich_text/bulleted_list_text.dart'; import 'package:appflowy_editor/src/render/rich_text/checkbox_text.dart'; import 'package:appflowy_editor/src/render/rich_text/heading_text.dart'; import 'package:appflowy_editor/src/render/rich_text/number_list_text.dart'; import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text.dart'; +import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; +import 'package:flutter/material.dart' hide Overlay, OverlayEntry; NodeWidgetBuilders defaultBuilders = { 'editor': EditorEntryWidgetBuilder(), @@ -33,6 +32,8 @@ class AppFlowyEditor extends StatefulWidget { this.toolbarItems = const [], this.editable = true, this.autoFocus = false, + this.focusedSelection, + this.customActionMenuBuilder, ThemeData? themeData, }) : super(key: key) { this.themeData = themeData ?? @@ -60,6 +61,10 @@ class AppFlowyEditor extends StatefulWidget { /// Set the value to true to focus the editor on the start of the document. final bool autoFocus; + final Selection? focusedSelection; + + final Positioned Function(BuildContext context, List items)? + customActionMenuBuilder; @override State createState() => _AppFlowyEditorState(); @@ -86,7 +91,8 @@ class _AppFlowyEditorState extends State { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { if (widget.editable && widget.autoFocus) { editorState.service.selectionService.updateSelection( - Selection.single(path: [0], startOffset: 0), + widget.focusedSelection ?? + Selection.single(path: [0], startOffset: 0), ); } }); @@ -171,5 +177,6 @@ class _AppFlowyEditorState extends State { ...defaultBuilders, ...widget.customBuilders, }, + customActionMenuBuilder: widget.customActionMenuBuilder, ); } diff --git a/lib/src/service/input_service.dart b/lib/src/service/input_service.dart index e3665650b..7d17aa9a2 100644 --- a/lib/src/service/input_service.dart +++ b/lib/src/service/input_service.dart @@ -324,4 +324,15 @@ class _AppFlowyInputState extends State } } } + + @override + void didChangeInputControl( + TextInputControl? oldControl, TextInputControl? newControl) { + // TODO: implement didChangeInputControl + } + + @override + void performSelector(String selectorName) { + // TODO: implement performSelector + } } diff --git a/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart b/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart index d612f839a..2d1953c68 100644 --- a/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -322,6 +322,33 @@ ShortcutEventHandler cursorRightWordSelect = (editorState, event) { return KeyEventResult.handled; }; +ShortcutEventHandler cursorLeftWordDelete = (editorState, event) { + final textNodes = editorState.service.selectionService.currentSelectedNodes + .whereType(); + final selection = editorState.service.selectionService.currentSelection.value; + + if (textNodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + + final textNode = textNodes.first; + + final startOfWord = + selection.end.goLeft(editorState, selectionRange: _SelectionRange.word); + + if (startOfWord == null) { + return KeyEventResult.ignored; + } + + final transaction = editorState.transaction; + transaction.deleteText( + textNode, startOfWord.offset, selection.end.offset - startOfWord.offset); + + editorState.apply(transaction); + + return KeyEventResult.handled; +}; + enum _SelectionRange { character, word, diff --git a/lib/src/service/internal_key_event_handlers/backspace_handler.dart b/lib/src/service/internal_key_event_handlers/backspace_handler.dart index ac2f14cc1..2a1f9db32 100644 --- a/lib/src/service/internal_key_event_handlers/backspace_handler.dart +++ b/lib/src/service/internal_key_event_handlers/backspace_handler.dart @@ -12,8 +12,9 @@ ShortcutEventHandler backspaceEventHandler = (editorState, event) { nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); selection = selection.isBackward ? selection : selection.reversed; final textNodes = nodes.whereType().toList(); - final List nonTextNodes = - nodes.where((node) => node is! TextNode).toList(growable: false); + final List nonTextNodes = nodes + .where((node) => node is! TextNode && node.selectable != null) + .toList(growable: false); final transaction = editorState.transaction; List? cancelNumberListPath; @@ -253,8 +254,8 @@ void _deleteTextNodes( final last = textNodes.last; var content = textNodes.last.toPlainText(); content = content.substring(selection.end.offset, content.length); - // Merge the fist and the last text node content, - // and delete the all nodes expect for the first. + // Merge the first and the last text node content, + // and delete all the nodes except for the first. transaction ..deleteNodes(textNodes.sublist(1)) ..mergeText( diff --git a/lib/src/service/internal_key_event_handlers/checkbox_event_handler.dart b/lib/src/service/internal_key_event_handlers/checkbox_event_handler.dart new file mode 100644 index 000000000..55b449c49 --- /dev/null +++ b/lib/src/service/internal_key_event_handlers/checkbox_event_handler.dart @@ -0,0 +1,38 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +ShortcutEventHandler toggleCheckbox = (editorState, event) { + final selection = editorState.service.selectionService.currentSelection.value; + final nodes = editorState.service.selectionService.currentSelectedNodes; + final checkboxTextNodes = nodes + .where( + (element) => + element is TextNode && + element.subtype == BuiltInAttributeKey.checkbox, + ) + .toList(growable: false); + + if (selection == null || checkboxTextNodes.isEmpty) { + return KeyEventResult.ignored; + } + + bool isAllCheckboxesChecked = checkboxTextNodes + .every((node) => node.attributes[BuiltInAttributeKey.checkbox] == true); + final transaction = editorState.transaction; + transaction.afterSelection = selection; + + if (isAllCheckboxesChecked) { + //if all the checkboxes are checked, then make all of the checkboxes unchecked + for (final node in checkboxTextNodes) { + transaction.updateNode(node, {BuiltInAttributeKey.checkbox: false}); + } + } else { + //If any one of the checkboxes is unchecked then make all checkboxes checked + for (final node in checkboxTextNodes) { + transaction.updateNode(node, {BuiltInAttributeKey.checkbox: true}); + } + } + + editorState.apply(transaction); + return KeyEventResult.handled; +}; diff --git a/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 47b8c3967..3110a0e55 100644 --- a/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,7 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/infra/clipboard.dart'; import 'package:appflowy_editor/src/infra/html_converter.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart'; import 'package:flutter/material.dart'; diff --git a/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart b/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart index 94caff83b..b38d838fe 100644 --- a/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart +++ b/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart @@ -265,8 +265,9 @@ ShortcutEventHandler markdownLinkOrImageHandler = (editorState, event) { return KeyEventResult.handled; }; -// convert **abc** to bold abc. -ShortcutEventHandler doubleAsterisksToBold = (editorState, event) { +ShortcutEventHandler underscoreToItalicHandler = (editorState, event) { + // Obtain the selection and selected nodes of the current document through the 'selectionService' + // to determine whether the selection is collapsed and whether the selected node is a text node. final selectionService = editorState.service.selectionService; final selection = selectionService.currentSelection.value; final textNodes = selectionService.currentSelectedNodes.whereType(); @@ -275,53 +276,33 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) { } final textNode = textNodes.first; - final text = textNode.toPlainText().substring(0, selection.end.offset); - - // make sure the last two characters are **. - if (text.length < 2 || text[selection.end.offset - 1] != '*') { - return KeyEventResult.ignored; - } - - // find all the index of `*`. - final asteriskIndexes = []; - for (var i = 0; i < text.length; i++) { - if (text[i] == '*') { - asteriskIndexes.add(i); - } - } - - if (asteriskIndexes.length < 3) { - return KeyEventResult.ignored; - } - - // make sure the second to last and third to last asterisks are connected. - final thirdToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 3]; - final secondToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 2]; - final lastAsterisIndex = asteriskIndexes[asteriskIndexes.length - 1]; - if (secondToLastAsteriskIndex != thirdToLastAsteriskIndex + 1 || - lastAsterisIndex == secondToLastAsteriskIndex + 1) { + final text = textNode.toPlainText(); + // Determine if an 'underscore' already exists in the text node and only once. + final firstUnderscore = text.indexOf('_'); + final lastUnderscore = text.lastIndexOf('_'); + if (firstUnderscore == -1 || + firstUnderscore != lastUnderscore || + firstUnderscore == selection.start.offset - 1) { return KeyEventResult.ignored; } - // delete the last three asterisks. - // update the style of the text surround by `** **` to bold. + // Delete the previous 'underscore', + // update the style of the text surrounded by the two underscores to 'italic', // and update the cursor position. final transaction = editorState.transaction - ..deleteText(textNode, lastAsterisIndex, 1) - ..deleteText(textNode, thirdToLastAsteriskIndex, 2) + ..deleteText(textNode, firstUnderscore, 1) ..formatText( textNode, - thirdToLastAsteriskIndex, - selection.end.offset - thirdToLastAsteriskIndex - 3, + firstUnderscore, + selection.end.offset - firstUnderscore - 1, { - BuiltInAttributeKey.bold: true, - BuiltInAttributeKey.defaultFormating: true, + BuiltInAttributeKey.italic: true, }, ) ..afterSelection = Selection.collapsed( Position( path: textNode.path, - offset: selection.end.offset - 3, + offset: selection.end.offset - 1, ), ); editorState.apply(transaction); @@ -329,111 +310,115 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) { return KeyEventResult.handled; }; -// convert __abc__ to bold abc. -ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) { +ShortcutEventHandler doubleAsteriskToBoldHanlder = (editorState, event) { final selectionService = editorState.service.selectionService; final selection = selectionService.currentSelection.value; final textNodes = selectionService.currentSelectedNodes.whereType(); + if (selection == null || !selection.isSingle || textNodes.length != 1) { return KeyEventResult.ignored; } final textNode = textNodes.first; - final text = textNode.toPlainText().substring(0, selection.end.offset); + final text = textNode.toPlainText(); - // make sure the last two characters are __. - if (text.length < 2 || text[selection.end.offset - 1] != '_') { +// make sure the last two characters are '**' + if (text.length < 2 || text[selection.end.offset - 1] != '*') { return KeyEventResult.ignored; } - // find all the index of `_`. - final underscoreIndexes = []; +// find all the index of '*' + final asteriskIndexList = []; for (var i = 0; i < text.length; i++) { - if (text[i] == '_') { - underscoreIndexes.add(i); + if (text[i] == '*') { + asteriskIndexList.add(i); } } - if (underscoreIndexes.length < 3) { - return KeyEventResult.ignored; - } + if (asteriskIndexList.length < 3) return KeyEventResult.ignored; - // make sure the second to last and third to last underscores are connected. - final thirdToLastUnderscoreIndex = - underscoreIndexes[underscoreIndexes.length - 3]; - final secondToLastUnderscoreIndex = - underscoreIndexes[underscoreIndexes.length - 2]; - final lastAsterisIndex = underscoreIndexes[underscoreIndexes.length - 1]; - if (secondToLastUnderscoreIndex != thirdToLastUnderscoreIndex + 1 || - lastAsterisIndex == secondToLastUnderscoreIndex + 1) { +// make sure the second to last and third to last asterisk are connected + final thirdToLastAsteriskIndex = + asteriskIndexList[asteriskIndexList.length - 3]; + final secondToLastAsteriskIndex = + asteriskIndexList[asteriskIndexList.length - 2]; + final lastAsteriskIndex = asteriskIndexList[asteriskIndexList.length - 1]; + if (secondToLastAsteriskIndex != thirdToLastAsteriskIndex + 1 || + lastAsteriskIndex == secondToLastAsteriskIndex + 1) { return KeyEventResult.ignored; } - // delete the last three underscores. - // update the style of the text surround by `__ __` to bold. - // and update the cursor position. +//delete the last three asterisks +//update the style of the text surround by '** **' to bold +//update the cursor position final transaction = editorState.transaction - ..deleteText(textNode, lastAsterisIndex, 1) - ..deleteText(textNode, thirdToLastUnderscoreIndex, 2) - ..formatText( - textNode, - thirdToLastUnderscoreIndex, - selection.end.offset - thirdToLastUnderscoreIndex - 3, - { - BuiltInAttributeKey.bold: true, - BuiltInAttributeKey.defaultFormating: true, - }, - ) + ..deleteText(textNode, lastAsteriskIndex, 1) + ..deleteText(textNode, thirdToLastAsteriskIndex, 2) + ..formatText(textNode, thirdToLastAsteriskIndex, + selection.end.offset - thirdToLastAsteriskIndex - 2, { + BuiltInAttributeKey.bold: true, + }) ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 3, - ), - ); + Position(path: textNode.path, offset: selection.end.offset - 3)); + editorState.apply(transaction); + return KeyEventResult.handled; }; -ShortcutEventHandler underscoreToItalicHandler = (editorState, event) { - // Obtain the selection and selected nodes of the current document through the 'selectionService' - // to determine whether the selection is collapsed and whether the selected node is a text node. +//Implement in the same way as doubleAsteriskToBoldHanlder +ShortcutEventHandler doubleUnderscoreToBoldHanlder = (editorState, event) { final selectionService = editorState.service.selectionService; final selection = selectionService.currentSelection.value; final textNodes = selectionService.currentSelectedNodes.whereType(); + if (selection == null || !selection.isSingle || textNodes.length != 1) { return KeyEventResult.ignored; } final textNode = textNodes.first; final text = textNode.toPlainText(); - // Determine if an 'underscore' already exists in the text node and only once. - final firstUnderscore = text.indexOf('_'); - final lastUnderscore = text.lastIndexOf('_'); - if (firstUnderscore == -1 || - firstUnderscore != lastUnderscore || - firstUnderscore == selection.start.offset - 1) { + +// make sure the last two characters are '__' + if (text.length < 2 || text[selection.end.offset - 1] != '_') { return KeyEventResult.ignored; } - // Delete the previous 'underscore', - // update the style of the text surrounded by the two underscores to 'italic', - // and update the cursor position. +// find all the index of '_' + final underscoreIndexList = []; + for (var i = 0; i < text.length; i++) { + if (text[i] == '_') { + underscoreIndexList.add(i); + } + } + + if (underscoreIndexList.length < 3) return KeyEventResult.ignored; + +// make sure the second to last and third to last underscore are connected + final thirdToLastUnderscoreIndex = + underscoreIndexList[underscoreIndexList.length - 3]; + final secondToLastUnderscoreIndex = + underscoreIndexList[underscoreIndexList.length - 2]; + final lastUnderscoreIndex = + underscoreIndexList[underscoreIndexList.length - 1]; + if (secondToLastUnderscoreIndex != thirdToLastUnderscoreIndex + 1 || + lastUnderscoreIndex == secondToLastUnderscoreIndex + 1) { + return KeyEventResult.ignored; + } + +//delete the last three underscores +//update the style of the text surround by '__ __' to bold +//update the cursor position final transaction = editorState.transaction - ..deleteText(textNode, firstUnderscore, 1) - ..formatText( - textNode, - firstUnderscore, - selection.end.offset - firstUnderscore - 1, - { - BuiltInAttributeKey.italic: true, - }, - ) + ..deleteText(textNode, lastUnderscoreIndex, 1) + ..deleteText(textNode, thirdToLastUnderscoreIndex, 2) + ..formatText(textNode, thirdToLastUnderscoreIndex, + selection.end.offset - thirdToLastUnderscoreIndex - 2, { + BuiltInAttributeKey.bold: true, + }) ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 1, - ), - ); + Position(path: textNode.path, offset: selection.end.offset - 3)); + editorState.apply(transaction); return KeyEventResult.handled; diff --git a/lib/src/service/internal_key_event_handlers/tab_handler.dart b/lib/src/service/internal_key_event_handlers/tab_handler.dart index 3b3091bbb..1b079e03c 100644 --- a/lib/src/service/internal_key_event_handlers/tab_handler.dart +++ b/lib/src/service/internal_key_event_handlers/tab_handler.dart @@ -14,7 +14,8 @@ ShortcutEventHandler tabHandler = (editorState, event) { final textNode = textNodes.first; final previous = textNode.previous; - if (textNode.subtype != BuiltInAttributeKey.bulletedList) { + if (textNode.subtype != BuiltInAttributeKey.bulletedList && + textNode.subtype != BuiltInAttributeKey.checkbox) { final transaction = editorState.transaction ..insertText(textNode, selection.end.offset, ' ' * 4); editorState.apply(transaction); @@ -22,7 +23,8 @@ ShortcutEventHandler tabHandler = (editorState, event) { } if (previous == null || - previous.subtype != BuiltInAttributeKey.bulletedList) { + (previous.subtype != BuiltInAttributeKey.bulletedList && + previous.subtype != BuiltInAttributeKey.checkbox)) { return KeyEventResult.ignored; } diff --git a/lib/src/service/internal_key_event_handlers/whitespace_handler.dart b/lib/src/service/internal_key_event_handlers/whitespace_handler.dart index 582173356..1b3f1abc8 100644 --- a/lib/src/service/internal_key_event_handlers/whitespace_handler.dart +++ b/lib/src/service/internal_key_event_handlers/whitespace_handler.dart @@ -56,7 +56,9 @@ ShortcutEventHandler whiteSpaceHandler = (editorState, event) { } else if (numberMatch != null) { final matchText = numberMatch.group(0); final numText = numberMatch.group(1); - if (matchText != null && numText != null) { + if (matchText != null && + numText != null && + matchText.length == selection.startIndex) { return _toNumberList(editorState, textNode, matchText, numText); } } diff --git a/lib/src/service/keyboard_service.dart b/lib/src/service/keyboard_service.dart index fad31c711..a25b7a6f1 100644 --- a/lib/src/service/keyboard_service.dart +++ b/lib/src/service/keyboard_service.dart @@ -35,7 +35,10 @@ abstract class AppFlowyKeyboardService { /// you can disable the keyboard service of flowy_editor. /// But you need to call the `enable` function to restore after exiting /// your custom component, otherwise the keyboard service will fails. - void disable({bool showCursor = false}); + void disable({ + bool showCursor = false, + UnfocusDisposition disposition = UnfocusDisposition.scope, + }); } /// Process keyboard events @@ -102,10 +105,13 @@ class _AppFlowyKeyboardState extends State } @override - void disable({bool showCursor = false}) { + void disable({ + bool showCursor = false, + UnfocusDisposition disposition = UnfocusDisposition.scope, + }) { isFocus = false; this.showCursor = showCursor; - _focusNode.unfocus(); + _focusNode.unfocus(disposition: disposition); } @override diff --git a/lib/src/service/render_plugin_service.dart b/lib/src/service/render_plugin_service.dart index e2aec3c4b..24adece17 100644 --- a/lib/src/service/render_plugin_service.dart +++ b/lib/src/service/render_plugin_service.dart @@ -1,6 +1,8 @@ import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/src/editor_state.dart'; import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:appflowy_editor/src/render/action_menu/action_menu.dart'; +import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -29,6 +31,9 @@ abstract class AppFlowyRenderPluginService { /// UnRegister plugin with specified [name]. void unRegister(String name); + /// Returns a [NodeWidgetBuilder], if one has been registered for [name] + NodeWidgetBuilder? getBuilder(String name); + Widget buildPluginWidget(NodeWidgetContext context); } @@ -57,9 +62,13 @@ class NodeWidgetContext { } class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { + final Positioned Function(BuildContext context, List items)? + customActionMenuBuilder; + AppFlowyRenderPlugin({ required this.editorState, required NodeWidgetBuilders builders, + this.customActionMenuBuilder, }) { registerAll(builders); } @@ -106,6 +115,11 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { _builders.remove(name); } + @override + NodeWidgetBuilder? getBuilder(String name) { + return _builders[name]; + } + Widget _autoUpdateNodeWidget( NodeWidgetBuilder builder, NodeWidgetContext context) { Widget notifier; @@ -116,7 +130,7 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { return Consumer( builder: ((_, value, child) { Log.ui.debug('TextNode is rebuilding...'); - return builder.build(context); + return _buildWithActions(builder, context); }), ); }); @@ -127,7 +141,7 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { return Consumer( builder: ((_, value, child) { Log.ui.debug('Node is rebuilding...'); - return builder.build(context); + return _buildWithActions(builder, context); }), ); }); @@ -138,6 +152,22 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { ); } + Widget _buildWithActions( + NodeWidgetBuilder builder, NodeWidgetContext context) { + if (builder is ActionProvider) { + return ChangeNotifierProvider( + create: (_) => ActionMenuState(context.node.path), + child: ActionMenuOverlay( + items: builder.actions(context), + customActionMenuBuilder: customActionMenuBuilder, + child: builder.build(context), + ), + ); + } else { + return builder.build(context); + } + } + void _validatePlugin(String name) { final paths = name.split('/'); if (paths.length > 2) { diff --git a/lib/src/service/selection_service.dart b/lib/src/service/selection_service.dart index 6e63de874..b522aa9ce 100644 --- a/lib/src/service/selection_service.dart +++ b/lib/src/service/selection_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/infra/log.dart'; import 'package:appflowy_editor/src/service/context_menu/built_in_context_menu_item.dart'; @@ -121,6 +123,9 @@ class _AppFlowySelectionState extends State EditorState get editorState => widget.editorState; + // Toolbar + Timer? _toolbarTimer; + @override void initState() { super.initState(); @@ -144,6 +149,7 @@ class _AppFlowySelectionState extends State clearSelection(); WidgetsBinding.instance.removeObserver(this); currentSelection.removeListener(_onSelectionChange); + _clearToolbar(); super.dispose(); } @@ -236,7 +242,7 @@ class _AppFlowySelectionState extends State // clear cursor areas // hide toolbar - editorState.service.toolbarService?.hide(); + // editorState.service.toolbarService?.hide(); // clear context menu _clearContextMenu(); @@ -482,13 +488,8 @@ class _AppFlowySelectionState extends State Overlay.of(context)?.insertAll(_selectionAreas); - if (toolbarOffset != null && layerLink != null) { - editorState.service.toolbarService?.showInOffset( - toolbarOffset, - alignment!, - layerLink, - ); - } + // show toolbar + _showToolbarWithDelay(toolbarOffset, layerLink, alignment!); } void _updateCursorAreas(Position position) { @@ -502,6 +503,7 @@ class _AppFlowySelectionState extends State currentSelectedNodes = [node]; _showCursor(node, position); + _clearToolbar(); } void _showCursor(Node node, Position position) { @@ -628,6 +630,40 @@ class _AppFlowySelectionState extends State _scrollUpOrDownIfNeeded(); } + void _showToolbarWithDelay( + Offset? toolbarOffset, + LayerLink? layerLink, + Alignment alignment, { + Duration delay = const Duration(milliseconds: 400), + }) { + if (toolbarOffset == null && layerLink == null) { + _clearToolbar(); + return; + } + if (_toolbarTimer?.isActive ?? false) { + _toolbarTimer?.cancel(); + } + _toolbarTimer = Timer( + delay, + () { + if (toolbarOffset != null && layerLink != null) { + editorState.service.toolbarService?.showInOffset( + toolbarOffset, + alignment, + layerLink, + ); + } + }, + ); + } + + void _clearToolbar() { + editorState.service.toolbarService?.hide(); + if (_toolbarTimer?.isActive ?? false) { + _toolbarTimer?.cancel(); + } + } + void _showDebugLayerIfNeeded({Offset? offset}) { // remove false to show debug overlay. // if (kDebugMode && false) { diff --git a/lib/src/service/shortcut_event/built_in_shortcut_events.dart b/lib/src/service/shortcut_event/built_in_shortcut_events.dart index ad05a636d..d6338b6fe 100644 --- a/lib/src/service/shortcut_event/built_in_shortcut_events.dart +++ b/lib/src/service/shortcut_event/built_in_shortcut_events.dart @@ -1,5 +1,3 @@ -// List<> - import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; @@ -14,6 +12,7 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_s import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/checkbox_event_handler.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; import 'package:flutter/foundation.dart'; @@ -49,15 +48,26 @@ List builtInShortcutEvents = [ handler: cursorDownSelect, ), ShortcutEvent( - key: 'Cursor down select', + key: 'Cursor left word select', command: 'shift+alt+arrow left', + windowsCommand: 'shift+alt+arrow left', + linuxCommand: 'shift+alt+arrow left', handler: cursorLeftWordSelect, ), ShortcutEvent( - key: 'Cursor down select', + key: 'Cursor right word select', command: 'shift+alt+arrow right', + windowsCommand: 'shift+alt+arrow right', + linuxCommand: 'shift+alt+arrow right', handler: cursorRightWordSelect, ), + ShortcutEvent( + key: 'Cursor word delete', + command: 'meta+backspace', + windowsCommand: 'ctrl+backspace', + linuxCommand: 'ctrl+backspace', + handler: cursorLeftWordDelete, + ), ShortcutEvent( key: 'Cursor left select', command: 'shift+arrow left', @@ -159,6 +169,13 @@ List builtInShortcutEvents = [ linuxCommand: 'ctrl+u', handler: formatUnderlineEventHandler, ), + ShortcutEvent( + key: 'Toggle Checkbox', + command: 'meta+enter', + windowsCommand: 'ctrl+enter', + linuxCommand: 'ctrl+enter', + handler: toggleCheckbox, + ), ShortcutEvent( key: 'Format strikethrough', command: 'meta+shift+s', @@ -265,16 +282,6 @@ List builtInShortcutEvents = [ command: 'tab', handler: tabHandler, ), - ShortcutEvent( - key: 'Double stars to bold', - command: 'shift+asterisk', - handler: doubleAsterisksToBold, - ), - ShortcutEvent( - key: 'Double underscores to bold', - command: 'shift+underscore', - handler: doubleUnderscoresToBold, - ), ShortcutEvent( key: 'Backquote to code', command: 'backquote', @@ -300,6 +307,16 @@ List builtInShortcutEvents = [ command: 'shift+underscore', handler: underscoreToItalicHandler, ), + ShortcutEvent( + key: 'Double asterisk to bold', + command: 'shift+digit 8', + handler: doubleAsteriskToBoldHanlder, + ), + ShortcutEvent( + key: 'Double underscore to bold', + command: 'shift+underscore', + handler: doubleUnderscoreToBoldHanlder, + ), // https://github.com/flutter/flutter/issues/104944 // Workaround: Using space editing on the web platform often results in errors, // so adding a shortcut event to handle the space input instead of using the diff --git a/lib/src/service/toolbar_service.dart b/lib/src/service/toolbar_service.dart index 06343b374..9fd8ca364 100644 --- a/lib/src/service/toolbar_service.dart +++ b/lib/src/service/toolbar_service.dart @@ -7,7 +7,11 @@ import 'package:appflowy_editor/src/extensions/object_extensions.dart'; abstract class AppFlowyToolbarService { /// Show the toolbar widget beside the offset. - void showInOffset(Offset offset, Alignment alignment, LayerLink layerLink); + void showInOffset( + Offset offset, + Alignment alignment, + LayerLink layerLink, + ); /// Hide the toolbar widget. void hide(); @@ -45,7 +49,11 @@ class _FlowyToolbarState extends State } @override - void showInOffset(Offset offset, Alignment alignment, LayerLink layerLink) { + void showInOffset( + Offset offset, + Alignment alignment, + LayerLink layerLink, + ) { hide(); final items = _filterItems(toolbarItems); if (items.isEmpty) { @@ -78,7 +86,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/pubspec.yaml b/pubspec.yaml index 67b695554..73ff882d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: appflowy_editor -description: A highly customizable rich-text editor for Flutter -version: 0.0.9 +description: A highly customizable rich-text editor for Flutter. The AppFlowy Editor project for AppFlowy and beyond. +version: 0.1.1 homepage: https://github.com/AppFlowy-IO/AppFlowy platforms: @@ -10,8 +10,8 @@ platforms: web: environment: - sdk: ">=2.18.0 <3.0.0" - flutter: ">=3.3.0" + sdk: ">=2.19.0 <3.0.0" + flutter: ">=3.7.0" dependencies: flutter: diff --git a/test/command/command_extension_test.dart b/test/command/command_extension_test.dart new file mode 100644 index 000000000..1c7325987 --- /dev/null +++ b/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/test/core/document/attributes_test.dart b/test/core/document/attributes_test.dart index 873ab2788..a7b234994 100644 --- a/test/core/document/attributes_test.dart +++ b/test/core/document/attributes_test.dart @@ -54,6 +54,37 @@ void main() async { 'b': 3, 'c': 4, }); + expect(invertAttributes(null, base), { + 'a': null, + 'b': null, + }); + expect(invertAttributes(other, null), { + 'b': 3, + 'c': 4, + }); }); + test( + "hasAttributes", + () { + final base = { + 'a': 1, + 'b': 2, + }; + final other = { + 'c': 3, + 'd': 4, + }; + + var x = hashAttributes(base); + var y = hashAttributes(base); + // x & y should have same hash code + expect(x == y, true); + + y = hashAttributes(other); + + // x & y should have different hash code + expect(x == y, false); + }, + ); }); } diff --git a/test/core/document/node_iterator_test.dart b/test/core/document/node_iterator_test.dart index 05a0090ec..a816efbbc 100644 --- a/test/core/document/node_iterator_test.dart +++ b/test/core/document/node_iterator_test.dart @@ -1,5 +1,4 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; import 'package:flutter_test/flutter_test.dart'; void main() async { diff --git a/test/core/document/node_test.dart b/test/core/document/node_test.dart index 4e407fd32..853df0567 100644 --- a/test/core/document/node_test.dart +++ b/test/core/document/node_test.dart @@ -228,5 +228,23 @@ void main() async { final textNode = TextNode.empty()..delta = (Delta()..insert('AppFlowy')); expect(textNode.toPlainText(), 'AppFlowy'); }); + test('test node id', () { + final nodeA = Node( + type: 'example', + children: LinkedList(), + attributes: {}, + ); + final nodeAId = nodeA.id; + expect(nodeAId, 'example'); + final nodeB = Node( + type: 'example', + children: LinkedList(), + attributes: { + 'subtype': 'exampleSubtype', + }, + ); + final nodeBId = nodeB.id; + expect(nodeBId, 'example/exampleSubtype'); + }); }); } diff --git a/test/core/document/path_test.dart b/test/core/document/path_test.dart index cf11a96dd..fa2725db9 100644 --- a/test/core/document/path_test.dart +++ b/test/core/document/path_test.dart @@ -29,5 +29,43 @@ void main() async { expect(p2 <= p1, true); expect(p1.equals(p2), true); }); + test( + "test path next, previous and parent getters", + () { + var p1 = [0, 0]; + var p2 = [0, 1]; + + expect(p1.next.equals(p2), true); + expect(p1.previous.equals(p2), false); + expect(p1.parent.equals(p2), false); + + p1 = [0, 1, 0]; + p2 = [0, 1, 1]; + + expect(p2.next.equals(p1), false); + expect(p2.previous.equals(p1), true); + expect(p2.parent.equals(p1), false); + + p1 = [0, 1, 1]; + p2 = [0, 1, 1]; + + expect(p1.next.equals(p2), false); + expect(p1.previous.equals(p2), false); + expect(p1.parent.equals(p2), false); + + p1 = []; + p2 = []; + + expect(p1.next.equals(p2), true); + expect(p2.previous.equals(p1), true); + expect(p1.parent.equals(p2), true); + + p1 = [1, 0, 2]; + p2 = [1, 0]; + + expect(p1.parent.equals(p2), true); + expect(p2.parent.equals(p1), false); + }, + ); }); } diff --git a/test/extensions/node_extension_test.dart b/test/extensions/node_extension_test.dart index 70b18f22a..3c8b3b0cc 100644 --- a/test/extensions/node_extension_test.dart +++ b/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/test/infra/test_editor.dart b/test/infra/test_editor.dart index e5d171d41..55672fba4 100644 --- a/test/infra/test_editor.dart +++ b/test/infra/test_editor.dart @@ -68,7 +68,7 @@ class EditorWidgetTester { ); } - void insertImageNode(String src, {String? align}) { + void insertImageNode(String src, {String? align, double? width}) { insert( Node( type: 'image', @@ -76,6 +76,7 @@ class EditorWidgetTester { attributes: { 'image_src': src, 'align': align ?? 'center', + ...width != null ? {'width': width} : {}, }, ), ); @@ -161,6 +162,40 @@ class EditorWidgetTester { ..disableSealTimer = true ..disbaleRules = true; } + + bool runAction(int actionIndex, Node node) { + final builder = editorState.service.renderPluginService.getBuilder(node.id); + if (builder is! ActionProvider) { + return false; + } + + final buildContext = node.key.currentContext; + if (buildContext == null) { + return false; + } + + final context = node is TextNode + ? NodeWidgetContext( + context: buildContext, + node: node, + editorState: editorState, + ) + : NodeWidgetContext( + context: buildContext, + node: node, + editorState: editorState, + ); + + final actions = + builder.actions(context).where((a) => a.onPressed != null).toList(); + if (actionIndex > actions.length) { + return false; + } + + final action = actions[actionIndex]; + action.onPressed!(); + return true; + } } extension TestString on String { diff --git a/test/infra/test_raw_key_event.dart b/test/infra/test_raw_key_event.dart index 5102407e1..0a3d6cd74 100644 --- a/test/infra/test_raw_key_event.dart +++ b/test/infra/test_raw_key_event.dart @@ -136,18 +136,21 @@ extension on LogicalKeyboardKey { if (this == LogicalKeyboardKey.keyH) { return PhysicalKeyboardKey.keyH; } + if (this == LogicalKeyboardKey.keyQ) { + return PhysicalKeyboardKey.keyQ; + } if (this == LogicalKeyboardKey.keyZ) { return PhysicalKeyboardKey.keyZ; } - if (this == LogicalKeyboardKey.asterisk) { + if (this == LogicalKeyboardKey.tilde) { + return PhysicalKeyboardKey.backquote; + } + if (this == LogicalKeyboardKey.digit8) { return PhysicalKeyboardKey.digit8; } if (this == LogicalKeyboardKey.underscore) { return PhysicalKeyboardKey.minus; } - if (this == LogicalKeyboardKey.tilde) { - return PhysicalKeyboardKey.backquote; - } throw UnimplementedError(); } } diff --git a/test/plugins/markdown/encoder/document_markdown_encoder_test.dart b/test/plugins/markdown/encoder/document_markdown_encoder_test.dart index 0c3fdb025..5b104f322 100644 --- a/test/plugins/markdown/encoder/document_markdown_encoder_test.dart +++ b/test/plugins/markdown/encoder/document_markdown_encoder_test.dart @@ -114,7 +114,10 @@ void main() async { test('parser document', () async { final data = Map.from(json.decode(example)); final document = Document.fromJson(data); - final result = DocumentMarkdownEncoder().convert(document); + final result = DocumentMarkdownEncoder(parsers: [ + const TextNodeParser(), + const ImageNodeParser(), + ]).convert(document); expect(result, ''' ## 👋 **Welcome to** ***[AppFlowy Editor](appflowy.io)*** diff --git a/test/plugins/markdown/encoder/parser/divider_node_parser_test.dart b/test/plugins/markdown/encoder/parser/divider_node_parser_test.dart deleted file mode 100644 index f4cb58068..000000000 --- a/test/plugins/markdown/encoder/parser/divider_node_parser_test.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/divider_node_parser.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('divider_node_parser.dart', () { - test('parser divider node', () { - final node = Node( - type: 'divider', - ); - final result = const DividerNodeParser().transform(node); - expect(result, '---\n'); - }); - }); -} diff --git a/test/render/action_menu/action_menu_test.dart b/test/render/action_menu/action_menu_test.dart new file mode 100644 index 000000000..9725b5e0f --- /dev/null +++ b/test/render/action_menu/action_menu_test.dart @@ -0,0 +1,165 @@ +import 'package:appflowy_editor/src/render/action_menu/action_menu.dart'; +import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('action_menu.dart', () { + testWidgets('hover and tap action', (tester) async { + var actionHit = false; + + final widget = ActionMenuOverlay( + items: [ + ActionMenuItem.icon( + iconData: Icons.download, + onPressed: () => actionHit = true, + ) + ], + child: const SizedBox( + height: 100, + width: 100, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ChangeNotifierProvider( + create: (context) => ActionMenuState([]), + child: widget, + ), + ), + ), + ); + expect(find.byType(ActionMenuWidget), findsNothing); + + final actionMenuOverlay = find.byType(ActionMenuOverlay); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(actionMenuOverlay)); + await tester.pumpAndSettle(); + + final actionMenu = find.byType(ActionMenuWidget); + expect(actionMenu, findsOneWidget); + + final action = find.descendant( + of: actionMenu, + matching: find.byType(ActionMenuItemWidget), + ); + expect(action, findsOneWidget); + + await tester.tap(action); + expect(actionHit, true); + }); + + testWidgets('stacked action menu overlays', (tester) async { + final childWidget = ChangeNotifierProvider( + create: (context) => ActionMenuState([0, 0]), + child: ActionMenuOverlay( + items: [ + ActionMenuItem( + iconBuilder: ({color, size}) => const Text("child"), + onPressed: null, + ) + ], + child: const SizedBox( + height: 100, + width: 100, + ), + ), + ); + + final parentWidget = ChangeNotifierProvider( + create: (context) => ActionMenuState([0]), + child: ActionMenuOverlay( + items: [ + ActionMenuItem( + iconBuilder: ({color, size}) => const Text("parent"), + onPressed: null, + ) + ], + child: childWidget, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: parentWidget), + ), + ), + ); + expect(find.byType(ActionMenuWidget), findsNothing); + + final overlays = find.byType(ActionMenuOverlay); + expect( + tester.getCenter(overlays.at(0)), + tester.getCenter(overlays.at(1)), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(overlays.at(0))); + await tester.pumpAndSettle(); + + final actionMenu = find.byType(ActionMenuWidget); + expect(actionMenu, findsOneWidget); + + expect(find.text("child"), findsOneWidget); + expect(find.text("parent"), findsNothing); + }); + + testWidgets('customActionMenuBuilder', (tester) async { + final widget = ActionMenuOverlay( + items: [ + ActionMenuItem.icon( + iconData: Icons.download, + onPressed: null, + ) + ], + customActionMenuBuilder: (context, items) { + return const Positioned.fill( + child: Center( + child: Text("custom"), + ), + ); + }, + child: const SizedBox( + height: 100, + width: 100, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ChangeNotifierProvider( + create: (context) => ActionMenuState([]), + child: widget, + ), + ), + ), + ); + expect(find.text("custom"), findsNothing); + + final actionMenuOverlay = find.byType(ActionMenuOverlay); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(actionMenuOverlay)); + await tester.pumpAndSettle(); + + expect(find.text("custom"), findsOneWidget); + }); + }); +} diff --git a/test/render/image/image_node_builder_test.dart b/test/render/image/image_node_builder_test.dart index 201f07861..118d30e36 100644 --- a/test/render/image/image_node_builder_test.dart +++ b/test/render/image/image_node_builder_test.dart @@ -1,4 +1,3 @@ -import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; import 'package:appflowy_editor/src/service/editor_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,6 +21,7 @@ void main() async { ..insertImageNode(src) ..insertTextNode(text); await editor.startTesting(); + await tester.pumpAndSettle(); expect(editor.documentLength, 3); expect(find.byType(Image), findsOneWidget); @@ -35,11 +35,12 @@ void main() async { 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; final editor = tester.editor ..insertTextNode(text) - ..insertImageNode(src, align: 'left') - ..insertImageNode(src, align: 'center') - ..insertImageNode(src, align: 'right') + ..insertImageNode(src, align: 'left', width: 100) + ..insertImageNode(src, align: 'center', width: 100) + ..insertImageNode(src, align: 'right', width: 100) ..insertTextNode(text); await editor.startTesting(); + await tester.pumpAndSettle(); expect(editor.documentLength, 5); final imageFinder = find.byType(Image); @@ -60,20 +61,17 @@ void main() async { expect(leftImageRect.size, centerImageRect.size); expect(rightImageRect.size, centerImageRect.size); - final imageNodeWidgetFinder = find.byType(ImageNodeWidget); + final leftImageNode = editor.document.nodeAtPath([1]); - final leftImage = - tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; - - leftImage.onAlign(Alignment.center); - await tester.pump(const Duration(milliseconds: 100)); + expect(editor.runAction(1, leftImageNode!), true); // align center + await tester.pump(); expect( tester.getRect(imageFinder.at(0)).left, centerImageRect.left, ); - leftImage.onAlign(Alignment.centerRight); - await tester.pump(const Duration(milliseconds: 100)); + expect(editor.runAction(2, leftImageNode), true); // align right + await tester.pump(); expect( tester.getRect(imageFinder.at(0)).right, rightImageRect.right, @@ -96,10 +94,10 @@ void main() async { final imageFinder = find.byType(Image); expect(imageFinder, findsOneWidget); - final imageNodeWidgetFinder = find.byType(ImageNodeWidget); - final image = - tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; - image.onCopy(); + final imageNode = editor.document.nodeAtPath([1]); + + expect(editor.runAction(3, imageNode!), true); // copy + await tester.pump(); }); }); @@ -119,10 +117,8 @@ void main() async { final imageFinder = find.byType(Image); expect(imageFinder, findsNWidgets(2)); - final imageNodeWidgetFinder = find.byType(ImageNodeWidget); - final image = - tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; - image.onDelete(); + final imageNode = editor.document.nodeAtPath([1]); + expect(editor.runAction(4, imageNode!), true); // delete await tester.pump(const Duration(milliseconds: 100)); expect(editor.documentLength, 3); diff --git a/test/render/image/image_node_widget_test.dart b/test/render/image/image_node_widget_test.dart index a566b7ec0..f758983f7 100644 --- a/test/render/image/image_node_widget_test.dart +++ b/test/render/image/image_node_widget_test.dart @@ -2,7 +2,6 @@ import 'dart:collection'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; @@ -15,14 +14,12 @@ void main() async { group('image_node_widget.dart', () { testWidgets('build the image node widget', (tester) async { mockNetworkImagesFor(() async { - var onCopyHit = false; - var onDeleteHit = false; - var onAlignHit = false; const src = 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; final widget = ImageNodeWidget( src: src, + width: 100, node: Node( type: 'image', children: LinkedList(), @@ -32,15 +29,6 @@ void main() async { }, ), alignment: Alignment.center, - onCopy: () { - onCopyHit = true; - }, - onDelete: () { - onDeleteHit = true; - }, - onAlign: (alignment) { - onAlignHit = true; - }, onResize: (width) {}, ); @@ -51,41 +39,20 @@ void main() async { ), ), ); - expect(find.byType(ImageNodeWidget), findsOneWidget); + await tester.pumpAndSettle(); - final gesture = - await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); + final imageNodeFinder = find.byType(ImageNodeWidget); + expect(imageNodeFinder, findsOneWidget); - expect(find.byType(ImageToolbar), findsNothing); + final imageFinder = find.byType(Image); + expect(imageFinder, findsOneWidget); - addTearDown(gesture.removePointer); - await tester.pump(); - await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget))); - await tester.pump(); + final imageNodeRect = tester.getRect(imageNodeFinder); + final imageRect = tester.getRect(imageFinder); - expect(find.byType(ImageToolbar), findsOneWidget); - - final iconFinder = find.byType(IconButton); - expect(iconFinder, findsNWidgets(5)); - - await tester.tap(iconFinder.at(0)); - expect(onAlignHit, true); - onAlignHit = false; - - await tester.tap(iconFinder.at(1)); - expect(onAlignHit, true); - onAlignHit = false; - - await tester.tap(iconFinder.at(2)); - expect(onAlignHit, true); - onAlignHit = false; - - await tester.tap(iconFinder.at(3)); - expect(onCopyHit, true); - - await tester.tap(iconFinder.at(4)); - expect(onDeleteHit, true); + expect(imageRect.width, 100); + expect((imageNodeRect.left - imageRect.left).abs(), + (imageNodeRect.right - imageRect.right).abs()); }); }); }); diff --git a/test/render/rich_text/checkbox_text_test.dart b/test/render/rich_text/checkbox_text_test.dart index e396dbbf0..eceb42989 100644 --- a/test/render/rich_text/checkbox_text_test.dart +++ b/test/render/rich_text/checkbox_text_test.dart @@ -70,7 +70,7 @@ void main() async { }); // https://github.com/AppFlowy-IO/AppFlowy/issues/1763 - // [Bug] Mouse unable to click a certain area #1763 + // // [Bug] Mouse unable to click a certain area #1763 testWidgets('insert a new checkbox after an exsiting checkbox', (tester) async { // Before diff --git a/test/render/rich_text/toolbar_rich_text_test.dart b/test/render/rich_text/toolbar_rich_text_test.dart index b9e774b35..54f9ed045 100644 --- a/test/render/rich_text/toolbar_rich_text_test.dart +++ b/test/render/rich_text/toolbar_rich_text_test.dart @@ -25,6 +25,7 @@ void main() async { await editor.updateSelection(h1); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final h1Button = find.byWidgetPredicate((widget) { @@ -52,6 +53,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(h2); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final h2Button = find.byWidgetPredicate((widget) { @@ -77,6 +79,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(h3); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final h3Button = find.byWidgetPredicate((widget) { @@ -104,6 +107,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(underline); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final underlineButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -132,6 +136,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(bold); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final boldButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -159,6 +164,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(italic); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final italicButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -187,6 +193,7 @@ void main() async { await editor.updateSelection(strikeThrough); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final strikeThroughButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -214,6 +221,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(code); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final codeButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -250,6 +258,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(quote); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final quoteButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -276,6 +285,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(bulletList); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final bulletListButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -306,6 +316,7 @@ void main() async { end: Position(path: [0], offset: singleLineText.length)); await editor.updateSelection(selection); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final highlightButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { @@ -343,6 +354,7 @@ void main() async { ); await editor.updateSelection(selection); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); final colorButton = find.byWidgetPredicate((widget) { if (widget is ToolbarItemWidget) { diff --git a/test/render/selection_menu/selection_menu_widget_test.dart b/test/render/selection_menu/selection_menu_widget_test.dart index 99a4674ef..6b392cdeb 100644 --- a/test/render/selection_menu/selection_menu_widget_test.dart +++ b/test/render/selection_menu/selection_menu_widget_test.dart @@ -10,40 +10,44 @@ void main() async { }); group('selection_menu_widget.dart', () { - for (var i = 0; i < defaultSelectionMenuItems.length; i += 1) { - testWidgets('Selects number.$i item in selection menu with enter', - (tester) async { - final editor = await _prepare(tester); - for (var j = 0; j < i; j++) { - await editor.pressLogicKey(LogicalKeyboardKey.arrowDown); - } - - await editor.pressLogicKey(LogicalKeyboardKey.enter); - expect( - find.byType(SelectionMenuWidget, skipOffstage: false), - findsNothing, - ); - if (defaultSelectionMenuItems[i].name() != 'Image') { - await _testDefaultSelectionMenuItems(i, editor); - } - }); - - testWidgets('Selects number.$i item in selection menu with click', - (tester) async { - final editor = await _prepare(tester); + // const i = defaultSelectionMenuItems.length; + // + // Because the `defaultSelectionMenuItems` uses localization, + // and the MaterialApp has not been initialized at the time of getting the value, + // it will crash. + // + // Use const value temporarily instead. + const i = 7; + testWidgets('Selects number.$i item in selection menu with keyboard', + (tester) async { + final editor = await _prepare(tester); + for (var j = 0; j < i; j++) { + await editor.pressLogicKey(LogicalKeyboardKey.arrowDown); + } - await tester.tap(find.byType(SelectionMenuItemWidget).at(i)); - await tester.pumpAndSettle(); + await editor.pressLogicKey(LogicalKeyboardKey.enter); + expect( + find.byType(SelectionMenuWidget, skipOffstage: false), + findsNothing, + ); + if (defaultSelectionMenuItems[i].name != 'Image') { + await _testDefaultSelectionMenuItems(i, editor); + } + }); - expect( - find.byType(SelectionMenuWidget, skipOffstage: false), - findsNothing, - ); - if (defaultSelectionMenuItems[i].name() != 'Image') { - await _testDefaultSelectionMenuItems(i, editor); - } - }); - } + testWidgets('Selects number.$i item in selection menu with clicking', + (tester) async { + final editor = await _prepare(tester); + await tester.tap(find.byType(SelectionMenuItemWidget).at(i)); + await tester.pumpAndSettle(); + expect( + find.byType(SelectionMenuWidget, skipOffstage: false), + findsNothing, + ); + if (defaultSelectionMenuItems[i].name != 'Image') { + await _testDefaultSelectionMenuItems(i, editor); + } + }); testWidgets('Search item in selection menu util no results', (tester) async { @@ -136,7 +140,7 @@ Future _prepare(WidgetTester tester) async { ); for (final item in defaultSelectionMenuItems) { - expect(find.text(item.name()), findsOneWidget); + expect(find.text(item.name), findsOneWidget); } return Future.value(editor); @@ -146,28 +150,31 @@ Future _testDefaultSelectionMenuItems( int index, EditorWidgetTester editor) async { expect(editor.documentLength, 4); expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); + expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), + 'Welcome to Appflowy 😁'); expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), 'Welcome to Appflowy 😁'); final node = editor.nodeAtPath([2]); final item = defaultSelectionMenuItems[index]; - final itemName = item.name(); - if (itemName == 'Text') { + if (item.name == 'Text') { expect(node?.subtype == null, true); - } else if (itemName == 'Heading 1') { + expect(node?.toString(), null); + } else if (item.name == 'Heading 1') { expect(node?.subtype, BuiltInAttributeKey.heading); expect(node?.attributes.heading, BuiltInAttributeKey.h1); - } else if (itemName == 'Heading 2') { + expect(node?.toString(), null); + } else if (item.name == 'Heading 2') { expect(node?.subtype, BuiltInAttributeKey.heading); expect(node?.attributes.heading, BuiltInAttributeKey.h2); - } else if (itemName == 'Heading 3') { + expect(node?.toString(), null); + } else if (item.name == 'Heading 3') { expect(node?.subtype, BuiltInAttributeKey.heading); expect(node?.attributes.heading, BuiltInAttributeKey.h3); - } else if (itemName == 'Bulleted list') { + expect(node?.toString(), null); + } else if (item.name == 'Bulleted list') { expect(node?.subtype, BuiltInAttributeKey.bulletedList); - } else if (itemName == 'Checkbox') { + } else if (item.name == 'Checkbox') { expect(node?.subtype, BuiltInAttributeKey.checkbox); expect(node?.attributes.check, false); - } else if (itemName == 'Quote') { - expect(node?.subtype, BuiltInAttributeKey.quote); } } diff --git a/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart b/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart index fea620971..25e633e49 100644 --- a/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart +++ b/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart @@ -466,6 +466,133 @@ void main() async { ), ); }); + + testWidgets('Presses ctrl + backspace to delete a word', (tester) async { + List words = ["Welcome", " ", "to", " ", "Appflowy", " ", "😁"]; + final text = words.join(); + final editor = tester.editor..insertTextNode(text); + + await editor.startTesting(); + var selection = Selection.single(path: [0], startOffset: text.length); + await editor.updateSelection(selection); + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isMetaPressed: true, + ); + } + + //fetching all the text that is still on the editor. + var nodes = + editor.editorState.service.selectionService.currentSelectedNodes; + var textNode = nodes.whereType().first; + var newText = textNode.toPlainText(); + + words.removeLast(); + expect(newText, words.join()); + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isMetaPressed: true, + ); + } + + //fetching all the text that is still on the editor. + nodes = editor.editorState.service.selectionService.currentSelectedNodes; + textNode = nodes.whereType().first; + + newText = textNode.toPlainText(); + + words.removeLast(); + expect(newText, words.join()); + + for (var i = 0; i < words.length; i++) { + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isMetaPressed: true, + ); + } + } + + nodes = editor.editorState.service.selectionService.currentSelectedNodes; + textNode = nodes.whereType().toList(growable: false).first; + + newText = textNode.toPlainText(); + + expect(newText, ''); + }); + + testWidgets('Testing ctrl + backspace edge cases', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + + await editor.startTesting(); + var selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isMetaPressed: true, + ); + } + + //fetching all the text that is still on the editor. + var nodes = + editor.editorState.service.selectionService.currentSelectedNodes; + var textNode = nodes.whereType().first; + var newText = textNode.toPlainText(); + + //nothing happens + expect(newText, text); + + selection = Selection.single(path: [0], startOffset: 14); + await editor.updateSelection(selection); + //Welcome to App|flowy 😁 + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + isMetaPressed: true, + ); + } + + //fetching all the text that is still on the editor. + nodes = editor.editorState.service.selectionService.currentSelectedNodes; + textNode = nodes.whereType().first; + newText = textNode.toPlainText(); + + const expectedText = 'Welcome to flowy 😁'; + expect(newText, expectedText); + }); } Future _testPressArrowKeyInNotCollapsedSelection( diff --git a/test/service/internal_key_event_handlers/checkbox_event_handler_test.dart b/test/service/internal_key_event_handlers/checkbox_event_handler_test.dart new file mode 100644 index 000000000..e1fd1f6cc --- /dev/null +++ b/test/service/internal_key_event_handlers/checkbox_event_handler_test.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('checkbox_event_handler_test.dart', () { + testWidgets('toggle checkbox with shortcut ctrl+enter', (tester) async { + const text = 'Checkbox1'; + final editor = tester.editor + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: text.length), + ); + + final checkboxNode = editor.nodeAtPath([0]) as TextNode; + expect(checkboxNode.subtype, BuiltInAttributeKey.checkbox); + expect(checkboxNode.attributes[BuiltInAttributeKey.checkbox], false); + + for (final event in builtInShortcutEvents) { + if (event.key == 'Toggle Checkbox') { + event.updateCommand( + windowsCommand: 'ctrl+enter', + linuxCommand: 'ctrl+enter', + macOSCommand: 'meta+enter', + ); + } + } + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isMetaPressed: true, + ); + } + + expect(checkboxNode.attributes[BuiltInAttributeKey.checkbox], true); + + await editor.updateSelection( + Selection.single(path: [0], startOffset: text.length), + ); + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isMetaPressed: true, + ); + } + + expect(checkboxNode.attributes[BuiltInAttributeKey.checkbox], false); + }); + + testWidgets( + 'test if all checkboxes get unchecked after toggling them, if all of them were already checked', + (tester) async { + const text = 'Checkbox'; + final editor = tester.editor + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: true, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ) + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: true, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ) + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: true, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ); + + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: text.length), + ); + + final nodes = + editor.editorState.service.selectionService.currentSelectedNodes; + final checkboxTextNodes = nodes + .where( + (element) => + element is TextNode && + element.subtype == BuiltInAttributeKey.checkbox, + ) + .toList(growable: false); + + for (final node in checkboxTextNodes) { + expect(node.attributes[BuiltInAttributeKey.checkbox], true); + } + + for (final event in builtInShortcutEvents) { + if (event.key == 'Toggle Checkbox') { + event.updateCommand( + windowsCommand: 'ctrl+enter', + linuxCommand: 'ctrl+enter', + macOSCommand: 'meta+enter', + ); + } + } + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isMetaPressed: true, + ); + } + + for (final node in checkboxTextNodes) { + expect(node.attributes[BuiltInAttributeKey.checkbox], false); + } + }); + + testWidgets( + 'test if all checkboxes get checked after toggling them, if any one of them were already checked', + (tester) async { + const text = 'Checkbox'; + final editor = tester.editor + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ) + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: true, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ) + ..insertTextNode( + '', + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }, + delta: Delta( + operations: [TextInsert(text)], + ), + ); + + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: text.length), + ); + + final nodes = + editor.editorState.service.selectionService.currentSelectedNodes; + final checkboxTextNodes = nodes + .where( + (element) => + element is TextNode && + element.subtype == BuiltInAttributeKey.checkbox, + ) + .toList(growable: false); + + for (final event in builtInShortcutEvents) { + if (event.key == 'Toggle Checkbox') { + event.updateCommand( + windowsCommand: 'ctrl+enter', + linuxCommand: 'ctrl+enter', + macOSCommand: 'meta+enter', + ); + } + } + + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + isMetaPressed: true, + ); + } + + for (final node in checkboxTextNodes) { + expect(node.attributes[BuiltInAttributeKey.checkbox], true); + } + }); + }); +} diff --git a/test/service/internal_key_event_handlers/format_style_handler_test.dart b/test/service/internal_key_event_handlers/format_style_handler_test.dart index 0cb4c7160..222a7efe1 100644 --- a/test/service/internal_key_event_handlers/format_style_handler_test.dart +++ b/test/service/internal_key_event_handlers/format_style_handler_test.dart @@ -245,6 +245,7 @@ Future _testLinkMenuInSingleTextSelection(WidgetTester tester) async { await editor.updateSelection(selection); // show toolbar + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); // trigger the link menu diff --git a/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart b/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart deleted file mode 100644 index f39d58e6a..000000000 --- a/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart +++ /dev/null @@ -1,277 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('markdown_syntax_to_styled_text_handler.dart', () { - group('convert double asterisks to bold', () { - Future insertAsterisk( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.asterisk, - isShiftPressed: true, - ); - } - } - - testWidgets('**AppFlowy** to bold AppFlowy', (tester) async { - const text = '**AppFlowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('App**Flowy** to bold AppFlowy', (tester) async { - const text = 'App**Flowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 3, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async { - const text = '***AppFlowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 1, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), '*AppFlowy'); - }); - - testWidgets('**AppFlowy** application to bold AppFlowy only', - (tester) async { - const boldText = '**AppFlowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - - for (var i = 0; i < boldText.length; i++) { - await editor.insertText(textNode, boldText[i], i); - } - await insertAsterisk(editor); - final boldTextLength = boldText.replaceAll('*', '').length; - final appFlowyBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: boldTextLength, - ), - ); - expect(appFlowyBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('**** nothing changes', (tester) async { - const text = '***'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, false); - expect(textNode.toPlainText(), text); - }); - }); - - group('convert double underscores to bold', () { - Future insertUnderscore( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.underscore, - isShiftPressed: true, - ); - } - } - - testWidgets('__AppFlowy__ to bold AppFlowy', (tester) async { - const text = '__AppFlowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('App__Flowy__ to bold AppFlowy', (tester) async { - const text = 'App__Flowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 3, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async { - const text = '___AppFlowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 1, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), '_AppFlowy'); - }); - - testWidgets('__AppFlowy__ application to bold AppFlowy only', - (tester) async { - const boldText = '__AppFlowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - - for (var i = 0; i < boldText.length; i++) { - await editor.insertText(textNode, boldText[i], i); - } - await insertUnderscore(editor); - final boldTextLength = boldText.replaceAll('_', '').length; - final appFlowyBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: boldTextLength, - ), - ); - expect(appFlowyBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('____ nothing changes', (tester) async { - const text = '___'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, false); - expect(textNode.toPlainText(), text); - }); - }); - }); -} diff --git a/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart b/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart index 662c7982b..219fd0756 100644 --- a/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart +++ b/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart @@ -257,4 +257,192 @@ void main() async { }); }); }); + + group('convert double asterisk to bold', () { + Future insertAsterisk( + EditorWidgetTester editor, { + int repeat = 1, + }) async { + for (var i = 0; i < repeat; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.digit8, + isShiftPressed: true, + ); + } + } + + testWidgets('**AppFlowy** to bold AppFlowy', ((widgetTester) async { + const text = '**AppFlowy*'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertAsterisk(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 0, endOffset: textNode.toPlainText().length)); + + expect(allBold, true); + expect(textNode.toPlainText(), 'AppFlowy'); + })); + + testWidgets('App**Flowy** to bold AppFlowy', ((widgetTester) async { + const text = 'App**Flowy*'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertAsterisk(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 3, endOffset: textNode.toPlainText().length)); + + expect(allBold, true); + expect(textNode.toPlainText(), 'AppFlowy'); + })); + + testWidgets('***AppFlowy** to bold *AppFlowy', ((widgetTester) async { + const text = '***AppFlowy*'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertAsterisk(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 1, endOffset: textNode.toPlainText().length)); + + expect(allBold, true); + expect(textNode.toPlainText(), '*AppFlowy'); + })); + + testWidgets('**** nothing changes', ((widgetTester) async { + const text = '***'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertAsterisk(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 0, endOffset: textNode.toPlainText().length)); + + expect(allBold, false); + expect(textNode.toPlainText(), text); + })); + }); + + group('convert double underscore to bold', () { + Future insertUnderscore( + EditorWidgetTester editor, { + int repeat = 1, + }) async { + for (var i = 0; i < repeat; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.underscore, + isShiftPressed: true, + ); + } + } + + testWidgets('__AppFlowy__ to bold AppFlowy', ((widgetTester) async { + const text = '__AppFlowy_'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertUnderscore(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 0, endOffset: textNode.toPlainText().length)); + + expect(allBold, true); + expect(textNode.toPlainText(), 'AppFlowy'); + })); + + testWidgets('App__Flowy__ to bold AppFlowy', ((widgetTester) async { + const text = 'App__Flowy_'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertUnderscore(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 3, endOffset: textNode.toPlainText().length)); + + expect(allBold, true); + expect(textNode.toPlainText(), 'AppFlowy'); + })); + + testWidgets('__*AppFlowy__ to bold *AppFlowy', ((widgetTester) async { + const text = '__*AppFlowy_'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertUnderscore(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 1, endOffset: textNode.toPlainText().length)); + + expect(allBold, true); + expect(textNode.toPlainText(), '*AppFlowy'); + })); + + testWidgets('____ nothing changes', ((widgetTester) async { + const text = '___'; + final editor = widgetTester.editor..insertTextNode(''); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + + await insertUnderscore(editor); + + final allBold = textNode.allSatisfyBoldInSelection(Selection.single( + path: [0], startOffset: 0, endOffset: textNode.toPlainText().length)); + + expect(allBold, false); + expect(textNode.toPlainText(), text); + })); + }); } diff --git a/test/service/internal_key_event_handlers/slash_handler_test.dart b/test/service/internal_key_event_handlers/slash_handler_test.dart index c00036ba1..a6e08d5fa 100644 --- a/test/service/internal_key_event_handlers/slash_handler_test.dart +++ b/test/service/internal_key_event_handlers/slash_handler_test.dart @@ -29,7 +29,7 @@ void main() async { ); for (final item in defaultSelectionMenuItems) { - expect(find.text(item.name()), findsOneWidget); + expect(find.text(item.name), findsOneWidget); } await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); diff --git a/test/service/internal_key_event_handlers/tab_handler_test.dart b/test/service/internal_key_event_handlers/tab_handler_test.dart index 641282c55..4b88960c3 100644 --- a/test/service/internal_key_event_handlers/tab_handler_test.dart +++ b/test/service/internal_key_event_handlers/tab_handler_test.dart @@ -152,4 +152,121 @@ void main() async { ); }); }); + + testWidgets('press tab in checkbox/todo list', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode( + text, + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }, + ) + ..insertTextNode( + text, + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }, + ) + ..insertTextNode( + text, + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }, + ); + await editor.startTesting(); + var document = editor.document; + + var selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + // nothing happens + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 0), + ); + expect(editor.document.toJson(), document.toJson()); + + // Before + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + // After + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + + selection = Selection.single(path: [1], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 0], startOffset: 0), + ); + expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.checkbox); + expect(editor.nodeAtPath([1])!.subtype, BuiltInAttributeKey.checkbox); + expect(editor.nodeAtPath([2]), null); + expect(editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.checkbox); + + selection = Selection.single(path: [1], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 1], startOffset: 0), + ); + expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.checkbox); + expect(editor.nodeAtPath([1]), null); + expect(editor.nodeAtPath([2]), null); + expect(editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.checkbox); + expect(editor.nodeAtPath([0, 1])!.subtype, BuiltInAttributeKey.checkbox); + + // Before + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + // After + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + // [] Welcome to Appflowy 😁 + document = editor.document; + selection = Selection.single(path: [0, 0], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 0], startOffset: 0), + ); + expect(editor.document.toJson(), document.toJson()); + + selection = Selection.single(path: [0, 1], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 0, 0], startOffset: 0), + ); + expect( + editor.nodeAtPath([0])!.subtype, + BuiltInAttributeKey.checkbox, + ); + expect( + editor.nodeAtPath([0, 0])!.subtype, + BuiltInAttributeKey.checkbox, + ); + expect(editor.nodeAtPath([0, 1]), null); + expect( + editor.nodeAtPath([0, 0, 0])!.subtype, + BuiltInAttributeKey.checkbox, + ); + }); } diff --git a/test/service/selection_service_test.dart b/test/service/selection_service_test.dart index c4f8825d3..7d89dc4e0 100644 --- a/test/service/selection_service_test.dart +++ b/test/service/selection_service_test.dart @@ -31,6 +31,7 @@ void main() async { Selection.single(path: [1], startOffset: 0), ); + await tester.pumpAndSettle(const Duration(seconds: 1)); // tap at the ending await tester.tapAt(rect.centerRight); expect( diff --git a/test/service/toolbar_service_test.dart b/test/service/toolbar_service_test.dart index 6c2ab0915..86cd29705 100644 --- a/test/service/toolbar_service_test.dart +++ b/test/service/toolbar_service_test.dart @@ -24,6 +24,7 @@ void main() async { ); await editor.updateSelection(selection); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); // no link item @@ -72,6 +73,7 @@ void main() async { await editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: text.length), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); void testHighlight(bool expectedValue) { @@ -138,6 +140,7 @@ void main() async { await editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: text.length), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); var itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.h1'); expect(itemWidget.isHighlight, true); @@ -145,6 +148,7 @@ void main() async { await editor.updateSelection( Selection.single(path: [1], startOffset: 0, endOffset: text.length), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.quote'); expect(itemWidget.isHighlight, true); @@ -152,6 +156,7 @@ void main() async { await editor.updateSelection( Selection.single(path: [2], startOffset: 0, endOffset: text.length), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.bulleted_list'); expect(itemWidget.isHighlight, true); @@ -183,6 +188,7 @@ void main() async { await editor.updateSelection( Selection.single(path: [2], startOffset: text.length, endOffset: 0), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); expect( _itemWidgetForId(tester, 'appflowy.toolbar.h1').isHighlight, @@ -199,6 +205,7 @@ void main() async { end: Position(path: [1], offset: 0), ), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); expect( _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, @@ -211,6 +218,7 @@ void main() async { end: Position(path: [0], offset: 0), ), ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(ToolbarWidget), findsOneWidget); expect( _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight,