From f5c6dc2ce7859a6852daefb26f1fc0b150573b7e Mon Sep 17 00:00:00 2001 From: Mayur Mahajan <47064215+MayurSMahajan@users.noreply.github.com> Date: Thu, 10 Aug 2023 16:38:45 +0530 Subject: [PATCH] feat: find dialog 1760 (#106) * feat: make find menu widget * feat: service for find menu * feat: add find menu shortcut event * feat: create a search service * docs: explain search service * fix: unhighlight method takes searched word * fix: unhighlight before each search * feat: navigate between matches * feat: forget highlighting from undo stack * feat: replace logic and ui * feat: replace shortcut handler and widget * test: find functionality * test: replace menu tests * refactor: separate class for search algo * refactor: suggested changes * refactor: remove unhighlight method * feat: add find highlight color * refactor: name of the search algo class * test: unit tests for search algorithm * chore: simplify syntax * test: renamed test group * refactor: move to editor * refactor: add shortcut for find * refactor: use new api * refactor: add abstrac class search algo * fix: avoid multiple instances of find dialog * refactor: xazin's suggestions * chore: separately build input decor * test: search algorithm * chore: unhighlight properly * refactor: replace handler * refactor: move tests into new * test: find menu widget test * test: replace menu * fix: localizations + resolve scroll bug partially * fix: without update selection on highlight * fix: do not select words when highlighting * test: update expected selection * fix: matches and styling * feat: separate attribute for highlighting * feat: unique color for selected match * test: unique color for selected match * refactor: cleaning the code * fix: navigate to first match * fix: tests and replace logic --------- Co-authored-by: Mathias Mogensen Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> --- example/lib/pages/editor.dart | 15 +- example/windows/runner/CMakeLists.txt | 7 + example/windows/runner/Runner.rc | 10 +- .../rich_text/appflowy_rich_text.dart | 11 + .../rich_text/appflowy_rich_text_keys.dart | 1 + .../standard_block_components.dart | 1 + lib/src/editor/command/text_commands.dart | 11 +- .../service/scroll_service_widget.dart | 13 +- .../selection/desktop_selection_service.dart | 4 +- .../command_shortcut_events.dart | 1 + .../find_replace_command.dart | 117 ++++++ .../find_replace_menu/find_menu_service.dart | 115 ++++++ .../find_replace_widget.dart | 177 ++++++++++ .../find_replace_menu/search_algorithm.dart | 56 +++ .../find_replace_menu/search_service.dart | 262 ++++++++++++++ .../desktop/items/color/color_menu.dart | 2 + .../background_color_options_widgets.dart | 1 + .../color/text_color_options_widgets.dart | 1 + .../editor/toolbar/utils/format_color.dart | 18 +- lib/src/editor_state.dart | 6 +- lib/src/history/undo_manager.dart | 7 + .../find_replace_menu_find_test.dart | 334 ++++++++++++++++++ .../find_replace_menu_replace_test.dart | 243 +++++++++++++ .../find_replace_menu_utils.dart | 144 ++++++++ .../search_algorithm_test.dart | 71 ++++ test/new/infra/testable_editor.dart | 27 +- 26 files changed, 1630 insertions(+), 25 deletions(-) create mode 100644 lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/find_replace_command.dart create mode 100644 lib/src/editor/find_replace_menu/find_menu_service.dart create mode 100644 lib/src/editor/find_replace_menu/find_replace_widget.dart create mode 100644 lib/src/editor/find_replace_menu/search_algorithm.dart create mode 100644 lib/src/editor/find_replace_menu/search_service.dart create mode 100644 test/new/find_replace_menu/find_replace_menu_find_test.dart create mode 100644 test/new/find_replace_menu/find_replace_menu_replace_test.dart create mode 100644 test/new/find_replace_menu/find_replace_menu_utils.dart create mode 100644 test/new/find_replace_menu/search_algorithm_test.dart diff --git a/example/lib/pages/editor.dart b/example/lib/pages/editor.dart index 45cb2965d..1cbfbf0a1 100644 --- a/example/lib/pages/editor.dart +++ b/example/lib/pages/editor.dart @@ -124,7 +124,20 @@ class Editor extends StatelessWidget { editorState: editorState, scrollController: scrollController, blockComponentBuilders: customBlockComponentBuilders, - commandShortcutEvents: standardCommandShortcutEvents, + commandShortcutEvents: [ + ...standardCommandShortcutEvents, + ...findAndReplaceCommands( + context: context, + localizations: FindReplaceLocalizations( + find: 'Find', + previousMatch: 'Previous match', + nextMatch: 'Next match', + close: 'Close', + replace: 'Replace', + replaceAll: 'Replace all', + ), + ), + ], characterShortcutEvents: standardCharacterShortcutEvents, ); } diff --git a/example/windows/runner/CMakeLists.txt b/example/windows/runner/CMakeLists.txt index c16efd198..5e69c2b90 100644 --- a/example/windows/runner/CMakeLists.txt +++ b/example/windows/runner/CMakeLists.txt @@ -20,6 +20,13 @@ add_executable(${BINARY_NAME} WIN32 # that need different build settings. apply_standard_settings(${BINARY_NAME}) +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + # Disable Windows macros that collide with C++ standard library functions. target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc index fa4251082..54114569a 100644 --- a/example/windows/runner/Runner.rc +++ b/example/windows/runner/Runner.rc @@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0 +#define VERSION_AS_NUMBER 1,0,0,0 #endif -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION #else #define VERSION_AS_STRING "1.0.0" #endif diff --git a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart index 5d38d0757..55935aa69 100644 --- a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart +++ b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart @@ -302,6 +302,11 @@ class _AppFlowyRichTextState extends State TextStyle(backgroundColor: attributes.backgroundColor), ); } + if (attributes.findBackgroundColor != null) { + textStyle = textStyle.combine( + TextStyle(backgroundColor: attributes.findBackgroundColor), + ); + } if (attributes.color != null) { textStyle = textStyle.combine( TextStyle(color: attributes.color), @@ -407,6 +412,12 @@ extension AppFlowyRichTextAttributes on Attributes { return highlightColor?.toColor(); } + Color? get findBackgroundColor { + final findBackgroundColor = + this[AppFlowyRichTextKeys.findBackgroundColor] as String?; + return findBackgroundColor?.toColor(); + } + String? get href { if (this[AppFlowyRichTextKeys.href] is String) { return this[AppFlowyRichTextKeys.href]; diff --git a/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart b/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart index 76c9fb2ae..5d0456b43 100644 --- a/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart +++ b/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart @@ -5,6 +5,7 @@ class AppFlowyRichTextKeys { static String strikethrough = 'strikethrough'; static String textColor = 'font_color'; static String highlightColor = 'bg_color'; + static String findBackgroundColor = 'find_bg_color'; static String code = 'code'; static String href = 'href'; diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index c5708b412..59f3ca1fe 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -117,6 +117,7 @@ final List standardCommandShortcutEvents = [ indentCommand, outdentCommand, + // exitEditingCommand, // diff --git a/lib/src/editor/command/text_commands.dart b/lib/src/editor/command/text_commands.dart index 352bd83f6..da05e17e6 100644 --- a/lib/src/editor/command/text_commands.dart +++ b/lib/src/editor/command/text_commands.dart @@ -132,7 +132,11 @@ extension TextTransforms on EditorState { /// format the delta at the given selection. /// /// If the [Selection] is not passed in, use the current selection. - Future formatDelta(Selection? selection, Attributes attributes) async { + Future formatDelta( + Selection? selection, + Attributes attributes, [ + bool withUpdateSelection = true, + ]) async { selection ??= this.selection; selection = selection?.normalized; @@ -164,7 +168,10 @@ extension TextTransforms on EditorState { ..afterSelection = transaction.beforeSelection; } - return apply(transaction); + return apply( + transaction, + withUpdateSelection: withUpdateSelection, + ); } /// Toggles the given attribute on or off for the selected text. diff --git a/lib/src/editor/editor_component/service/scroll_service_widget.dart b/lib/src/editor/editor_component/service/scroll_service_widget.dart index 0de8cc618..8292d9de9 100644 --- a/lib/src/editor/editor_component/service/scroll_service_widget.dart +++ b/lib/src/editor/editor_component/service/scroll_service_widget.dart @@ -104,17 +104,28 @@ class _ScrollServiceWidgetState extends State // should auto scroll after the cursor or selection updated. final selection = editorState.selection; if (selection == null || - editorState.selectionUpdateReason == SelectionUpdateReason.selectAll) { + [SelectionUpdateReason.selectAll, SelectionUpdateReason.searchHighlight] + .contains(editorState.selectionUpdateReason)) { return; } + final updateReason = editorState.selectionUpdateReason; final selectionType = editorState.selectionType; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { final selectionRect = editorState.selectionRects(); if (selectionRect.isEmpty) { return; } + final endTouchPoint = selectionRect.last.centerRight; + + if (editorState.selectionUpdateReason == + SelectionUpdateReason.searchNavigate) { + scrollController.jumpTo(endTouchPoint.dy - 100); + return; + } + if (selection.isCollapsed) { if (PlatformExtension.isMobile) { // soft keyboard diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index d7daac0bf..1a4c6e1e2 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -131,9 +131,11 @@ class _DesktopSelectionServiceWidgetState void _updateSelection() { final selection = editorState.selection; + // TODO: why do we need to check this? if (currentSelection.value == selection && - editorState.selectionUpdateReason == SelectionUpdateReason.uiEvent && + [SelectionUpdateReason.uiEvent, SelectionUpdateReason.searchHighlight] + .contains(editorState.selectionUpdateReason) && editorState.selectionType != SelectionType.block) { return; } diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/command_shortcut_events.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/command_shortcut_events.dart index 8ac0e6d04..3480422e8 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/command_shortcut_events.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/command_shortcut_events.dart @@ -14,6 +14,7 @@ export 'page_down_command.dart'; export 'page_up_command.dart'; export 'paste_command.dart'; export 'remove_word_command.dart'; +export 'find_replace_command.dart'; export 'select_all_command.dart'; export 'show_link_menu_command.dart'; export 'undo_redo_command.dart'; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/find_replace_command.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/find_replace_command.dart new file mode 100644 index 000000000..4882219a0 --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/find_replace_command.dart @@ -0,0 +1,117 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/find_replace_menu/find_menu_service.dart'; +import 'package:flutter/material.dart'; + +List findAndReplaceCommands({ + required FindReplaceLocalizations localizations, + required BuildContext context, + FindReplaceStyle? style, +}) => + [ + openFindDialog( + localizations: localizations, + context: context, + style: style ?? FindReplaceStyle(), + ), + openReplaceDialog( + localizations: localizations, + context: context, + style: style ?? FindReplaceStyle(), + ), + ]; + +class FindReplaceStyle { + FindReplaceStyle({ + this.selectedHighlightColor = const Color(0xFFFFB931), + this.unselectedHighlightColor = const Color(0x60ECBC5F), + }); + + //selected highlight color is used as background color on the selected found pattern. + final Color selectedHighlightColor; + //unselected highlight color is used on every other found pattern which can be selected. + final Color unselectedHighlightColor; +} + +class FindReplaceLocalizations { + FindReplaceLocalizations({ + required this.find, + required this.previousMatch, + required this.nextMatch, + required this.close, + required this.replace, + required this.replaceAll, + }); + + final String find; + final String previousMatch; + final String nextMatch; + final String close; + final String replace; + final String replaceAll; +} + +/// Show the slash menu +/// +/// - support +/// - desktop +/// - web +/// +CommandShortcutEvent openFindDialog({ + required FindReplaceLocalizations localizations, + required BuildContext context, + required FindReplaceStyle style, +}) => + CommandShortcutEvent( + key: 'show the find dialog', + command: 'ctrl+f', + macOSCommand: 'cmd+f', + handler: (editorState) => _showFindAndReplaceDialog( + context, + editorState, + localizations: localizations, + style: style, + ), + ); + +CommandShortcutEvent openReplaceDialog({ + required FindReplaceLocalizations localizations, + required BuildContext context, + required FindReplaceStyle style, +}) => + CommandShortcutEvent( + key: 'show the find and replace dialog', + command: 'ctrl+h', + macOSCommand: 'cmd+h', + handler: (editorState) => _showFindAndReplaceDialog( + context, + editorState, + localizations: localizations, + style: style, + openReplace: true, + ), + ); + +FindReplaceService? _findReplaceService; +KeyEventResult _showFindAndReplaceDialog( + BuildContext context, + EditorState editorState, { + required FindReplaceLocalizations localizations, + required FindReplaceStyle style, + bool openReplace = false, +}) { + if (PlatformExtension.isMobile) { + return KeyEventResult.ignored; + } + + _findReplaceService = FindReplaceMenu( + context: context, + editorState: editorState, + replaceFlag: openReplace, + localizations: localizations, + style: style, + ); + + _findReplaceService?.show(); + + return KeyEventResult.handled; +} diff --git a/lib/src/editor/find_replace_menu/find_menu_service.dart b/lib/src/editor/find_replace_menu/find_menu_service.dart new file mode 100644 index 000000000..669e16cba --- /dev/null +++ b/lib/src/editor/find_replace_menu/find_menu_service.dart @@ -0,0 +1,115 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_widget.dart'; +import 'package:flutter/material.dart'; + +abstract class FindReplaceService { + void show(); + void dismiss(); +} + +OverlayEntry? _findReplaceMenuEntry; + +class FindReplaceMenu implements FindReplaceService { + FindReplaceMenu({ + required this.context, + required this.editorState, + required this.replaceFlag, + required this.localizations, + required this.style, + }); + + final BuildContext context; + final EditorState editorState; + final bool replaceFlag; + final FindReplaceLocalizations localizations; + final FindReplaceStyle style; + + final double topOffset = 52; + final double rightOffset = 40; + + bool _selectionUpdateByInner = false; + + @override + void dismiss() { + if (_findReplaceMenuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _findReplaceMenuEntry?.remove(); + _findReplaceMenuEntry = null; + + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + selectionService.currentSelection.removeListener(_onSelectionChange); + } + } + + @override + void show() { + if (_findReplaceMenuEntry != null) { + dismiss(); + } + + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + + _findReplaceMenuEntry = OverlayEntry( + builder: (context) { + return Positioned( + top: topOffset, + right: rightOffset, + child: Material( + borderRadius: BorderRadius.circular(8.0), + child: DecoratedBox( + decoration: BoxDecoration( + color: editorState.editorStyle.selectionColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: FindMenuWidget( + dismiss: dismiss, + editorState: editorState, + replaceFlag: replaceFlag, + localizations: localizations, + style: style, + ), + ), + ), + ); + }, + ); + + Overlay.of(context).insert(_findReplaceMenuEntry!); + } + + void _onSelectionChange() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + if (selectionService.currentSelection.value == null) { + return; + } + } + + if (_selectionUpdateByInner) { + _selectionUpdateByInner = false; + return; + } + + dismiss(); + } +} diff --git a/lib/src/editor/find_replace_menu/find_replace_widget.dart b/lib/src/editor/find_replace_menu/find_replace_widget.dart new file mode 100644 index 000000000..ed4b21089 --- /dev/null +++ b/lib/src/editor/find_replace_menu/find_replace_widget.dart @@ -0,0 +1,177 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/find_replace_menu/search_service.dart'; +import 'package:flutter/material.dart'; + +const double _iconSize = 20; + +class FindMenuWidget extends StatefulWidget { + const FindMenuWidget({ + super.key, + required this.dismiss, + required this.editorState, + required this.replaceFlag, + required this.localizations, + required this.style, + }); + + final VoidCallback dismiss; + final EditorState editorState; + final bool replaceFlag; + final FindReplaceLocalizations localizations; + final FindReplaceStyle style; + + @override + State createState() => _FindMenuWidgetState(); +} + +class _FindMenuWidgetState extends State { + final focusNode = FocusNode(); + final findController = TextEditingController(); + final replaceController = TextEditingController(); + String queriedPattern = ''; + bool replaceFlag = false; + late SearchService searchService; + + @override + void initState() { + super.initState(); + replaceFlag = widget.replaceFlag; + searchService = SearchService( + editorState: widget.editorState, + style: SearchStyle( + selectedHighlightColor: widget.style.selectedHighlightColor, + unselectedHighlightColor: widget.style.unselectedHighlightColor, + ), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + + findController.addListener(_searchPattern); + } + + @override + void dispose() { + findController.removeListener(_searchPattern); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + IconButton( + onPressed: () => setState( + () => replaceFlag = !replaceFlag, + ), + icon: replaceFlag + ? const Icon(Icons.expand_less) + : const Icon(Icons.expand_more), + ), + SizedBox( + width: 200, + height: 30, + child: TextField( + key: const Key('findTextField'), + focusNode: focusNode, + controller: findController, + onSubmitted: (_) => searchService.navigateToMatch(), + decoration: _buildInputDecoration(widget.localizations.find), + ), + ), + IconButton( + key: const Key('previousMatchButton'), + iconSize: _iconSize, + onPressed: () => searchService.navigateToMatch(moveUp: true), + icon: const Icon(Icons.arrow_upward), + tooltip: widget.localizations.previousMatch, + ), + IconButton( + key: const Key('nextMatchButton'), + iconSize: _iconSize, + onPressed: () => searchService.navigateToMatch(), + icon: const Icon(Icons.arrow_downward), + tooltip: widget.localizations.nextMatch, + ), + IconButton( + key: const Key('closeButton'), + iconSize: _iconSize, + onPressed: () { + widget.dismiss(); + searchService.findAndHighlight( + queriedPattern, + unhighlight: true, + ); + queriedPattern = ''; + }, + icon: const Icon(Icons.close), + tooltip: widget.localizations.close, + ), + ], + ), + replaceFlag + ? Row( + children: [ + SizedBox( + width: 200, + height: 30, + child: TextField( + key: const Key('replaceTextField'), + autofocus: false, + controller: replaceController, + onSubmitted: (_) => _replaceSelectedWord(), + decoration: + _buildInputDecoration(widget.localizations.replace), + ), + ), + IconButton( + onPressed: () => _replaceSelectedWord(), + icon: const Icon(Icons.find_replace), + iconSize: _iconSize, + tooltip: widget.localizations.replace, + ), + IconButton( + key: const Key('replaceAllButton'), + onPressed: () => _replaceAllMatches(), + icon: const Icon(Icons.change_circle_outlined), + iconSize: _iconSize, + tooltip: widget.localizations.replaceAll, + ), + ], + ) + : const SizedBox.shrink(), + ], + ); + } + + void _searchPattern() { + searchService.findAndHighlight(findController.text); + setState(() => queriedPattern = findController.text); + } + + void _replaceSelectedWord() { + if (findController.text != queriedPattern) { + _searchPattern(); + } + searchService.replaceSelectedWord(replaceController.text); + } + + void _replaceAllMatches() { + if (findController.text != queriedPattern) { + _searchPattern(); + } + searchService.replaceAllMatches(replaceController.text); + } + + InputDecoration _buildInputDecoration(String hintText) { + return InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + border: const OutlineInputBorder(), + hintText: hintText, + ); + } +} diff --git a/lib/src/editor/find_replace_menu/search_algorithm.dart b/lib/src/editor/find_replace_menu/search_algorithm.dart new file mode 100644 index 000000000..048e7fab2 --- /dev/null +++ b/lib/src/editor/find_replace_menu/search_algorithm.dart @@ -0,0 +1,56 @@ +import 'dart:math' as math; + +/// If someone wants to use their own implementation for the search algorithm +/// They can do so by extending this abstract class and overriding its +/// `searchMethod(String pattern, String text)`, here `pattern` is the sequence of +/// characters that are to be searched within the `text`. +abstract class SearchAlgorithm { + List searchMethod(String pattern, String text); +} + +class BoyerMoore extends SearchAlgorithm { + //This is a standard algorithm used for searching patterns in long text samples + //It is more efficient than brute force searching because it is able to skip + //characters that will never possibly match with required pattern. + @override + List searchMethod(String pattern, String text) { + int m = pattern.length; + int n = text.length; + + Map badchar = {}; + List matches = []; + + _badCharHeuristic(pattern, m, badchar); + + int s = 0; + + while (s <= (n - m)) { + int j = m - 1; + + while (j >= 0 && pattern[j] == text[s + j]) { + j--; + } + + //if pattern is present at current shift, the index will become -1 + if (j < 0) { + matches.add(s); + s += (s + m < n) ? m - (badchar[text[s + m]] ?? -1) : 1; + } else { + s += math.max(1, j - (badchar[text[s + j]] ?? -1)); + } + } + + return matches; + } + + void _badCharHeuristic(String pat, int size, Map badchar) { + badchar.clear(); + + // Fill the actual value of last occurrence of a character + // (indices of table are characters and values are index of occurrence) + for (int i = 0; i < size; i++) { + String ch = pat[i]; + badchar[ch] = i; + } + } +} diff --git a/lib/src/editor/find_replace_menu/search_service.dart b/lib/src/editor/find_replace_menu/search_service.dart new file mode 100644 index 000000000..916e71e39 --- /dev/null +++ b/lib/src/editor/find_replace_menu/search_service.dart @@ -0,0 +1,262 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/find_replace_menu/search_algorithm.dart'; +import 'package:flutter/material.dart'; + +class SearchStyle { + SearchStyle({ + this.selectedHighlightColor = const Color(0xFFFFB931), + this.unselectedHighlightColor = const Color(0x60ECBC5F), + }); + + //selected highlight color is used as background color on the selected found pattern. + final Color selectedHighlightColor; + //unselected highlight color is used on every other found pattern which can be selected. + final Color unselectedHighlightColor; +} + +class SearchService { + SearchService({ + required this.editorState, + required this.style, + }); + + final EditorState editorState; + final SearchStyle style; + + //matchedPositions will contain a list of positions of the matched patterns + //the position here consists of the node and the starting offset of the + //matched pattern. We will use this to traverse between the matched patterns. + List matchedPositions = []; + SearchAlgorithm searchAlgorithm = BoyerMoore(); + String queriedPattern = ''; + int selectedIndex = 0; + + /// Finds the pattern in editorState.document and stores it in matchedPositions. + /// Calls the highlightMatch method to highlight the pattern + /// if it is found. + void findAndHighlight(String pattern, {bool unhighlight = false}) { + if (queriedPattern != pattern) { + //this means we have a new pattern, but before we highlight the new matches, + //lets unhiglight the old pattern + findAndHighlight(queriedPattern, unhighlight: true); + matchedPositions.clear(); + queriedPattern = pattern; + } + + if (pattern.isEmpty) return; + + //traversing all the nodes + for (final n in _getAllTextNodes()) { + //matches list will contain the offsets where the desired word, + //is found. + List matches = + searchAlgorithm.searchMethod(pattern, n.delta!.toPlainText()); + //we will store this list of offsets along with their path, + //in a list of positions. + for (int matchedOffset in matches) { + matchedPositions.add(Position(path: n.path, offset: matchedOffset)); + } + //finally we will highlight all the mathces. + _highlightMatches( + n.path, + matches, + pattern.length, + unhighlight: unhighlight, + ); + } + + selectedIndex = -1; + } + + List _getAllTextNodes() { + final contents = editorState.document.root.children; + + if (contents.isEmpty) return []; + + final firstNode = contents.firstWhere( + (el) => el.delta != null, + ); + + final lastNode = contents.lastWhere( + (el) => el.delta != null, + ); + + //iterate within all the text nodes of the document. + final nodes = NodeIterator( + document: editorState.document, + startNode: firstNode, + endNode: lastNode, + ).toList(); + + nodes.removeWhere((node) => node.delta == null); + + return nodes; + } + + /// This method takes in the TextNode's path, matches is a list of offsets, + /// patternLength is the length of the word which is being searched. + /// + /// So for example: path= 1, offset= 10, and patternLength= 5 will mean + /// that the word is located on path 1 from [1,10] to [1,14] + void _highlightMatches( + Path path, + List matches, + int patternLength, { + bool unhighlight = false, + }) { + for (final match in matches) { + final start = Position(path: path, offset: match); + final end = Position( + path: start.path, + offset: start.offset + queriedPattern.length, + ); + + final selection = Selection(start: start, end: end); + + if (unhighlight) { + editorState.formatDelta( + selection, + {AppFlowyRichTextKeys.findBackgroundColor: null}, + ); + } else { + _applySelectedHighlightColor(selection); + } + editorState.undoManager.forgetRecentUndo(); + } + } + + Future _selectWordAtPosition( + Position start, [ + bool isNavigating = false, + ]) async { + Position end = Position( + path: start.path, + offset: start.offset + queriedPattern.length, + ); + + final selection = Selection(start: start, end: end); + _applySelectedHighlightColor(selection, isSelected: true); + + await editorState.updateSelectionWithReason( + selection, + reason: isNavigating + ? SelectionUpdateReason.searchNavigate + : SelectionUpdateReason.searchHighlight, + ); + } + + /// This method takes in a boolean parameter moveUp, if set to true, + /// the match located above the current selected match is newly selected. + /// Otherwise the match below the current selected match is newly selected. + void navigateToMatch({bool moveUp = false}) { + if (matchedPositions.isEmpty) return; + + //lets change the highlight color to indicate that the current match is + //not selected. + if (selectedIndex > -1) { + final currentMatch = matchedPositions[selectedIndex]; + Position end = Position( + path: currentMatch.path, + offset: currentMatch.offset + queriedPattern.length, + ); + + final selection = Selection(start: currentMatch, end: end); + _applySelectedHighlightColor(selection); + } + + if (moveUp) { + selectedIndex = + selectedIndex - 1 < 0 ? matchedPositions.length - 1 : --selectedIndex; + } else { + selectedIndex = + (selectedIndex + 1) < matchedPositions.length ? ++selectedIndex : 0; + } + final match = matchedPositions[selectedIndex]; + _selectWordAtPosition(match, true); + } + + /// Replaces the current selected word with replaceText. + /// After replacing the selected word, this method selects the next + /// matched word if that exists. + void replaceSelectedWord(String replaceText, [bool fromFirst = false]) { + if (replaceText.isEmpty || + queriedPattern.isEmpty || + matchedPositions.isEmpty) { + return; + } + + if (selectedIndex == -1) { + selectedIndex++; + } + + final position = + fromFirst ? matchedPositions.first : matchedPositions[selectedIndex]; + _selectWordAtPosition(position); + + //unhighlight the selected word before it is replaced + final selection = editorState.selection!; + editorState.formatDelta( + selection, + {AppFlowyRichTextKeys.findBackgroundColor: null}, + ); + editorState.undoManager.forgetRecentUndo(); + + final textNode = editorState.getNodeAtPath(position.path)!; + + final transaction = editorState.transaction; + + transaction.replaceText( + textNode, + position.offset, + queriedPattern.length, + replaceText, + ); + + editorState.apply(transaction); + + if (fromFirst) { + matchedPositions.removeAt(0); + } else { + matchedPositions.removeAt(selectedIndex); + --selectedIndex; + + if (matchedPositions.isNotEmpty) { + if (selectedIndex == -1) { + selectedIndex = 0; + } + + _selectWordAtPosition(matchedPositions[selectedIndex]); + } + } + } + + /// Replaces all the found occurances of pattern with replaceText + void replaceAllMatches(String replaceText) { + if (replaceText.isEmpty || queriedPattern.isEmpty) { + return; + } + // We need to create a final variable matchesLength here, because + // when we replaceSelectedWord we reduce the length of matchedPositions + // list, this causes the value to shrink dynamically and thus it may + // result in pretermination. + final int matchesLength = matchedPositions.length; + + for (int i = 0; i < matchesLength; i++) { + replaceSelectedWord(replaceText, true); + } + } + + void _applySelectedHighlightColor( + Selection selection, { + bool isSelected = false, + }) { + final color = isSelected + ? style.selectedHighlightColor.toHex() + : style.unselectedHighlightColor.toHex(); + editorState.formatDelta( + selection, + {AppFlowyRichTextKeys.findBackgroundColor: color}, + false, + ); + } +} diff --git a/lib/src/editor/toolbar/desktop/items/color/color_menu.dart b/lib/src/editor/toolbar/desktop/items/color/color_menu.dart index cba8a5ab8..acd72e1d6 100644 --- a/lib/src/editor/toolbar/desktop/items/color/color_menu.dart +++ b/lib/src/editor/toolbar/desktop/items/color/color_menu.dart @@ -52,10 +52,12 @@ void showColorMenu( isTextColor ? formatFontColor( editorState, + editorState.selection, color, ) : formatHighlightColor( editorState, + editorState.selection, color, ); dismissOverlay(); diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/color/background_color_options_widgets.dart b/lib/src/editor/toolbar/mobile/toolbar_items/color/background_color_options_widgets.dart index 404da23ac..cee3a4220 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/color/background_color_options_widgets.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/color/background_color_options_widgets.dart @@ -72,6 +72,7 @@ class _BackgroundColorOptionsWidgetsState setState(() { formatHighlightColor( widget.editorState, + widget.editorState.selection, e.colorHex, ); }); diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart b/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart index 2888fd07a..87a22cc5a 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart @@ -70,6 +70,7 @@ class _TextColorOptionsWidgetsState extends State { setState(() { formatFontColor( widget.editorState, + widget.editorState.selection, e.colorHex, ); }); diff --git a/lib/src/editor/toolbar/utils/format_color.dart b/lib/src/editor/toolbar/utils/format_color.dart index 51f7763be..7602870c0 100644 --- a/lib/src/editor/toolbar/utils/format_color.dart +++ b/lib/src/editor/toolbar/utils/format_color.dart @@ -1,15 +1,25 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -void formatHighlightColor(EditorState editorState, String color) { +void formatHighlightColor( + EditorState editorState, + Selection? selection, + String color, +) { editorState.formatDelta( - editorState.selection, + selection, {AppFlowyRichTextKeys.highlightColor: color}, + false, ); } -void formatFontColor(EditorState editorState, String color) { +void formatFontColor( + EditorState editorState, + Selection? selection, + String color, +) { editorState.formatDelta( - editorState.selection, + selection, {AppFlowyRichTextKeys.textColor: color}, + false, ); } diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 03405dde2..1df1c438b 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -27,6 +27,8 @@ enum SelectionUpdateReason { uiEvent, // like mouse click, keyboard event transaction, // like insert, delete, format selectAll, + searchHighlight, // Highlighting search results + searchNavigate, // Navigate to a search result } enum SelectionType { @@ -242,9 +244,7 @@ class EditorState { } // TODO: execute this line after the UI has been updated. - { - completer.complete(); - } + completer.complete(); return completer.future; } diff --git a/lib/src/history/undo_manager.dart b/lib/src/history/undo_manager.dart index e935f7cfa..8a3ed45d2 100644 --- a/lib/src/history/undo_manager.dart +++ b/lib/src/history/undo_manager.dart @@ -149,4 +149,11 @@ class UndoManager { ), ); } + + void forgetRecentUndo() { + Log.editor.debug('forgetRecentUndo'); + if (state != null) { + undoStack.pop(); + } + } } diff --git a/test/new/find_replace_menu/find_replace_menu_find_test.dart b/test/new/find_replace_menu/find_replace_menu_find_test.dart new file mode 100644 index 000000000..e9a8d344e --- /dev/null +++ b/test/new/find_replace_menu/find_replace_menu_find_test.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../infra/testable_editor.dart'; +import 'find_replace_menu_utils.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('find_replace_menu.dart findMenu', () { + testWidgets('appears properly', (tester) async { + await prepareFindAndReplaceDialog(tester); + + // The prepareFindDialog method only checks if FindMenuWidget is present + // so here we also check if FindMenuWidget contains TextField + // and IconButtons or not. + expect(find.byType(TextField), findsOneWidget); + expect(find.byType(IconButton), findsAtLeastNWidgets(4)); + + await tester.editor.dispose(); + }); + + testWidgets('disappears when close is called', (tester) async { + await prepareFindAndReplaceDialog(tester); + + // Check if find menu disappears if the close button is tapped. + await tester.tap(find.byKey(const Key('closeButton'))); + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsNothing); + expect(find.byType(TextField), findsNothing); + expect(find.byType(IconButton), findsNothing); + + await tester.editor.dispose(); + }); + + testWidgets('does not highlight anything when empty string searched', + (tester) async { + // We expect nothing to be highlighted + await _prepareFindAndInputPattern(tester, '', true); + }); + + testWidgets('works properly when match is not found', (tester) async { + // We expect nothing to be highlighted + await _prepareFindAndInputPattern(tester, 'Flutter', true); + }); + + testWidgets('highlights properly when match is found', (tester) async { + // We expect something to be highlighted + await _prepareFindAndInputPattern(tester, 'Welcome', false); + }); + + testWidgets('selects found match', (tester) async { + const pattern = 'Welcome'; + + final editor = tester.editor; + editor.addParagraphs(3, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor); + + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsOneWidget); + + await enterInputIntoFindDialog(tester, pattern); + + // Checking if current selection consists an occurance of matched pattern. + final selection = + editor.editorState.service.selectionService.currentSelection.value; + + // We expect the first occurance of the pattern to be found and selected, + // this is because we send a testTextInput.receiveAction(TextInputAction.done) + // event during submitting our text input, thus the second match is selected. + expect(selection != null, true); + expect(selection!.start, Position(path: [0], offset: 0)); + expect(selection.end, Position(path: [0], offset: pattern.length)); + + await editor.dispose(); + }); + + testWidgets('navigating to previous and next matches works', + (tester) async { + const pattern = 'Welcome'; + const previousBtnKey = Key('previousMatchButton'); + const nextBtnKey = Key('nextMatchButton'); + + final editor = tester.editor; + editor.addParagraphs(2, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor); + + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsOneWidget); + + await enterInputIntoFindDialog(tester, pattern); + + // This will call naviateToMatch and select the first match + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + + // Checking if current selection consists an occurance of matched pattern. + // we expect the first occurance of the pattern to be found and selected + checkCurrentSelection(editor, [0], 0, pattern.length); + + // Now pressing the icon button for previous match should select + // node at path [1]. + await tester.tap(find.byKey(previousBtnKey)); + await tester.pumpAndSettle(); + + checkCurrentSelection(editor, [1], 0, pattern.length); + + await tester.tap(find.byKey(previousBtnKey)); + await tester.pumpAndSettle(); + + checkCurrentSelection(editor, [0], 0, pattern.length); + + await tester.tap(find.byKey(nextBtnKey)); + await tester.pumpAndSettle(); + + checkCurrentSelection(editor, [1], 0, pattern.length); + + await tester.tap(find.byKey(nextBtnKey)); + await tester.pumpAndSettle(); + + checkCurrentSelection(editor, [0], 0, pattern.length); + + await editor.dispose(); + }); + + testWidgets('''navigating - selected match is highlighted uniquely + than unselected matches''', (tester) async { + const pattern = 'Welcome'; + const previousBtnKey = Key('previousMatchButton'); + + final editor = tester.editor; + editor.addParagraphs(3, initialText: text); + + await editor.startTesting(); + + final node0 = editor.nodeAtPath([0]); + final selection0 = getSelectionAtPath([0], 0, pattern.length); + final node1 = editor.nodeAtPath([1]); + final selection1 = getSelectionAtPath([1], 0, pattern.length); + final node2 = editor.nodeAtPath([2]); + final selection2 = getSelectionAtPath([2], 0, pattern.length); + + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor); + + await tester.pumpAndSettle(); + + await enterInputIntoFindDialog(tester, pattern); + + // This will call naviateToMatch and select the first match + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + + // We expect the first occurance of the pattern to be found and selected + checkCurrentSelection(editor, [0], 0, pattern.length); + + // Check if the current selected match is highlighted properly + checkIfHighlightedWithProperColors(node0!, selection1, kSelectedHCHex); + + // Unselected matches are highlighted with different color + checkIfHighlightedWithProperColors(node1!, selection2, kUnselectedHCHex); + checkIfHighlightedWithProperColors(node2!, selection0, kUnselectedHCHex); + + // Press the icon button for previous match should select node at path [2] (last match) + await tester.tap(find.byKey(previousBtnKey)); + + checkCurrentSelection(editor, [2], 0, pattern.length); + checkIfHighlightedWithProperColors(node2, selection0, kSelectedHCHex); + checkIfHighlightedWithProperColors(node0, selection1, kUnselectedHCHex); + checkIfHighlightedWithProperColors(node1, selection2, kUnselectedHCHex); + + await editor.dispose(); + }); + + testWidgets('found matches are unhighlighted when findMenu closed', + (tester) async { + const pattern = 'Welcome'; + const closeBtnKey = Key('closeButton'); + + final editor = tester.editor; + editor.addParagraphs(3, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor); + + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsOneWidget); + + await enterInputIntoFindDialog(tester, pattern); + + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + + final selection = + editor.editorState.service.selectionService.currentSelection.value; + expect(selection, isNotNull); + + final node = editor.nodeAtPath([2]); + expect(node, isNotNull); + + // Node is highlighted while menu is active + checkIfNotHighlighted(node!, selection!, expectedResult: false); + + // Presses the close button + await tester.tap(find.byKey(closeBtnKey)); + await tester.pumpAndSettle(); + + // Closes the findMenuWidget + expect(find.byType(FindMenuWidget), findsNothing); + + // We expect that the current selected node is NOT highlighted. + checkIfNotHighlighted(node, selection, expectedResult: true); + + await editor.dispose(); + }); + + testWidgets('old matches are unhighlighted when new pattern is searched', + (tester) async { + const textLine1 = 'Welcome to Appflowy 😁'; + const textLine2 = 'Appflowy is made with Flutter, Rust and ❤️'; + var pattern = 'Welcome'; + + final editor = tester.editor + ..addParagraph(initialText: textLine1) + ..addParagraph(initialText: textLine2); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor); + + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsOneWidget); + + await enterInputIntoFindDialog(tester, pattern); + + // Since node at path [1] does not contain match, we expect it to not be highlighted. + final selectionAtNode1 = Selection.single( + path: [1], + startOffset: 0, + endOffset: textLine2.length, + ); + + Node? node = editor.nodeAtPath([1]); + expect(node, isNotNull); + + // We expect that the current node at path 1 to be NOT highlighted. + checkIfNotHighlighted(node!, selectionAtNode1, expectedResult: true); + + // Change the pattern to Flutter and search + pattern = 'Flutter'; + await enterInputIntoFindDialog(tester, pattern); + + // We expect that the current selected node is highlighted. + checkIfNotHighlighted(node, selectionAtNode1, expectedResult: false); + + final selectionAtNode0 = Selection.single( + path: [0], + startOffset: 0, + endOffset: textLine1.length, + ); + node = editor.nodeAtPath([0]); + expect(node, isNotNull); + + // We expect that the current node at path 0 to be NOT highlighted. + checkIfNotHighlighted(node!, selectionAtNode0, expectedResult: true); + + await editor.dispose(); + }); + }); +} + +Future _prepareFindAndInputPattern( + WidgetTester tester, + String pattern, + bool expectedResult, +) async { + final editor = tester.editor; + editor.addParagraph(initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor); + + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsOneWidget); + + await enterInputIntoFindDialog(tester, pattern); + + // Pressing enter should trigger the findAndHighlight method, which + // will find the pattern inside the editor. + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + + // Since the method will not select anything as searched pattern is + // empty, the current selection should be equal to previous selection. + final selection = + Selection.single(path: [0], startOffset: 0, endOffset: text.length); + + final node = editor.nodeAtPath([0]); + expect(node, isNotNull); + + checkIfNotHighlighted(node!, selection, expectedResult: expectedResult); + + await editor.dispose(); +} diff --git a/test/new/find_replace_menu/find_replace_menu_replace_test.dart b/test/new/find_replace_menu/find_replace_menu_replace_test.dart new file mode 100644 index 000000000..8f1bb7bf5 --- /dev/null +++ b/test/new/find_replace_menu/find_replace_menu_replace_test.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../infra/testable_editor.dart'; +import 'find_replace_menu_utils.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('find_replace_menu.dart replaceMenu', () { + testWidgets('replace menu appears properly', (tester) async { + await prepareFindAndReplaceDialog(tester, openReplace: true); + + // The prepare method only checks if FindMenuWidget is present + // so here we also check if FindMenuWidget contains TextField + // and IconButtons or not. + // and whether there are two textfields for replace menu as well. + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.byType(IconButton), findsAtLeastNWidgets(6)); + }); + + testWidgets('replace menu disappears when close is called', (tester) async { + await prepareFindAndReplaceDialog(tester, openReplace: true); + + await tester.tap(find.byKey(const Key('closeButton'))); + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsNothing); + expect(find.byType(TextField), findsNothing); + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets('replace menu does not work when find is not called', + (tester) async { + const pattern = 'Flutter'; + final editor = tester.editor; + editor.addParagraphs(1, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor, openReplace: true); + + await tester.pumpAndSettle(); + + expect(find.byType(FindMenuWidget), findsOneWidget); + + await enterInputIntoFindDialog(tester, pattern, isReplaceField: true); + + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + + //if nothing is replaced then the original text will remain as it is + final node = editor.nodeAtPath([0]); + expect(node!.delta!.toPlainText(), text); + + await editor.dispose(); + }); + + testWidgets('replace does not change text when no match is found', + (tester) async { + const pattern = 'Flutter'; + + final editor = tester.editor; + editor.addParagraphs(1, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor, openReplace: true); + + await tester.pumpAndSettle(); + expect(find.byType(FindMenuWidget), findsOneWidget); + + //we put the pattern in the find dialog and press enter + await enterInputIntoFindDialog(tester, pattern); + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + await tester.pumpAndSettle(); + + //now we input some text into the replace text field and try to replace + await enterInputIntoFindDialog(tester, pattern, isReplaceField: true); + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + await tester.pumpAndSettle(); + + final node = editor.nodeAtPath([0]); + expect(node!.delta!.toPlainText(), text); + await editor.dispose(); + }); + + //Before: + //Welcome to Appflowy 😁 + //After: + //Salute to Appflowy 😁 + testWidgets('found selected match is replaced properly', (tester) async { + const patternToBeFound = 'Welcome'; + const replacePattern = 'Salute'; + final expectedText = '$replacePattern${text.substring(7)}'; + + final editor = tester.editor; + editor.addParagraphs(1, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor, openReplace: true); + + await tester.pumpAndSettle(); + expect(find.byType(FindMenuWidget), findsOneWidget); + + //we put the pattern in the find dialog and press enter + await enterInputIntoFindDialog(tester, patternToBeFound); + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + await tester.pumpAndSettle(); + + //we expect the found pattern to be highlighted + final node = editor.nodeAtPath([0]); + final selection = + Selection.single(path: [0], startOffset: 0, endOffset: text.length); + + checkIfNotHighlighted(node!, selection, expectedResult: false); + + //now we input some text into the replace text field and try to replace + await enterInputIntoFindDialog( + tester, + replacePattern, + isReplaceField: true, + ); + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + await tester.pumpAndSettle(); + + expect(node.delta!.toPlainText(), expectedText); + + await editor.dispose(); + }); + + testWidgets('''within multiple matched patterns replace + should only replace the currently selected match''', (tester) async { + const patternToBeFound = 'Welcome'; + const replacePattern = 'Salute'; + final expectedText = '$replacePattern${text.substring(7)}'; + + final editor = tester.editor; + editor.addParagraphs(3, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor, openReplace: true); + + await tester.pumpAndSettle(); + expect(find.byType(FindMenuWidget), findsOneWidget); + + // we put the pattern in the find dialog and press enter + await enterInputIntoFindDialog(tester, patternToBeFound); + await editor.pressKey(key: LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + // lets check after find operation, the first match is selected. + checkCurrentSelection(editor, [0], 0, patternToBeFound.length); + + // now we input some text into the replace text field and try to replace + await enterInputIntoFindDialog( + tester, + replacePattern, + isReplaceField: true, + ); + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + await tester.pumpAndSettle(); + + //only the node at path 0 should get replaced, all other nodes should stay as before. + final lastNode = editor.nodeAtPath([0]); + expect(lastNode!.delta!.toPlainText(), expectedText); + + final middleNode = editor.nodeAtPath([1]); + expect(middleNode!.delta!.toPlainText(), text); + + final firstNode = editor.nodeAtPath([2]); + expect(firstNode!.delta!.toPlainText(), text); + + await editor.dispose(); + }); + + testWidgets('replace all on found matches', (tester) async { + const patternToBeFound = 'Welcome'; + const replacePattern = 'Salute'; + final expectedText = '$replacePattern${text.substring(7)}'; + const replaceAllBtn = Key('replaceAllButton'); + const lines = 3; + + final editor = tester.editor; + editor.addParagraphs(lines, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor, openReplace: true); + + await tester.pumpAndSettle(); + expect(find.byType(FindMenuWidget), findsOneWidget); + + //we put the pattern in the find dialog and press enter + await enterInputIntoFindDialog(tester, patternToBeFound); + await editor.pressKey( + key: LogicalKeyboardKey.enter, + ); + await tester.pumpAndSettle(); + + //now we input some text into the replace text field and try replace all + await enterInputIntoFindDialog( + tester, + replacePattern, + isReplaceField: true, + ); + + await tester.tap(find.byKey(replaceAllBtn)); + await tester.pumpAndSettle(); + + //all matches should be replaced + for (var i = 0; i < lines; i++) { + final node = editor.nodeAtPath([i]); + expect(node!.delta!.toPlainText(), expectedText); + } + await editor.dispose(); + }); + }); +} diff --git a/test/new/find_replace_menu/find_replace_menu_utils.dart b/test/new/find_replace_menu/find_replace_menu_utils.dart new file mode 100644 index 000000000..c11fb4586 --- /dev/null +++ b/test/new/find_replace_menu/find_replace_menu_utils.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../infra/testable_editor.dart'; + +const text = 'Welcome to Appflowy 😁'; +const Color kSelectedHighlightColor = Colors.yellow; +const Color kUnselectedHighlightColor = Colors.lightGreen; +String kSelectedHCHex = kSelectedHighlightColor.toHex(); +String kUnselectedHCHex = kUnselectedHighlightColor.toHex(); + +class TestableFindAndReplaceCommands { + TestableFindAndReplaceCommands({ + required this.context, + this.selectedHighlightColor = kSelectedHighlightColor, + this.unselectedHighlightColor = kUnselectedHighlightColor, + }); + + final Color selectedHighlightColor; + final Color unselectedHighlightColor; + final BuildContext context; + + List get testableFindAndReplaceCommands => + findAndReplaceCommands( + context: context, + localizations: FindReplaceLocalizations( + find: 'Find', + previousMatch: 'Previous match', + nextMatch: 'Next match', + close: 'Close', + replace: 'Replace', + replaceAll: 'Replace all', + ), + style: FindReplaceStyle( + selectedHighlightColor: selectedHighlightColor, + unselectedHighlightColor: unselectedHighlightColor, + ), + ); +} + +Future prepareFindAndReplaceDialog( + WidgetTester tester, { + bool openReplace = false, +}) async { + final editor = tester.editor; + editor.addParagraphs(3, initialText: text); + + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [0], startOffset: 0)); + + await pressFindAndReplaceCommand(editor, openReplace: openReplace); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + expect(find.byType(FindMenuWidget), findsOneWidget); +} + +Future enterInputIntoFindDialog( + WidgetTester tester, + String pattern, { + bool isReplaceField = false, +}) async { + final textInputKey = isReplaceField + ? const Key('replaceTextField') + : const Key('findTextField'); + await tester.tap(find.byKey(textInputKey)); + await tester.enterText(find.byKey(textInputKey), pattern); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); +} + +Future pressFindAndReplaceCommand( + TestableEditor editor, { + bool openReplace = false, +}) async { + await editor.pressKey( + key: openReplace ? LogicalKeyboardKey.keyH : LogicalKeyboardKey.keyF, + isMetaPressed: Platform.isMacOS, + isControlPressed: !Platform.isMacOS, + ); +} + +void checkIfNotHighlighted( + Node node, + Selection selection, { + bool expectedResult = true, +}) { + //if the expectedResult is true: + //we expect that nothing is highlighted in our current document. + //otherwise: we expect that something is highlighted. + expect( + node.allSatisfyInSelection(selection, (delta) { + return delta.whereType().every( + (e) => + e.attributes?[AppFlowyRichTextKeys.findBackgroundColor] == null, + ); + }), + expectedResult, + ); +} + +void checkIfHighlightedWithProperColors( + Node node, + Selection selection, + String expectedColor, +) { + expect( + node.allSatisfyInSelection(selection, (delta) { + return delta.whereType().every( + (e) => + e.attributes?[AppFlowyRichTextKeys.findBackgroundColor] == + expectedColor, + ); + }), + true, + ); +} + +void checkCurrentSelection( + TestableEditor editor, + Path path, + int startOffset, + int endOffset, +) { + final selection = + editor.editorState.service.selectionService.currentSelection.value; + + expect(selection != null, true); + expect(selection!.start, Position(path: path, offset: startOffset)); + expect(selection.end, Position(path: path, offset: endOffset)); +} + +Selection getSelectionAtPath(Path path, int startOffset, int endOffset) { + return Selection( + start: Position(path: path, offset: startOffset), + end: Position(path: path, offset: endOffset), + ); +} diff --git a/test/new/find_replace_menu/search_algorithm_test.dart b/test/new/find_replace_menu/search_algorithm_test.dart new file mode 100644 index 000000000..d2f079038 --- /dev/null +++ b/test/new/find_replace_menu/search_algorithm_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy_editor/src/editor/find_replace_menu/search_algorithm.dart'; + +void main() { + group('search_algorithm_test.dart', () { + late SearchAlgorithm searchAlgorithm; + + setUp(() { + searchAlgorithm = BoyerMoore(); + }); + + test('search algorithm returns the index of the only found pattern', () { + const pattern = 'Appflowy'; + const text = 'Welcome to Appflowy 😁'; + + List result = searchAlgorithm.searchMethod(pattern, text); + expect(result, [11]); + }); + + test('search algorithm returns the index of the multiple found patterns', + () { + const pattern = 'Appflowy'; + const text = ''' +Welcome to Appflowy 😁. Appflowy is an open-source alternative to Notion. +With Appflowy, you can build detailed lists of to-do for different +projects while tracking the status of each one. With Appflowy, you can +visualize items in a database moving through stages of a process, or +grouped by property. Design and modify Appflowy your way with an +open core codebase. Appflowy is built with Flutter and Rust. + '''; + + List result = searchAlgorithm.searchMethod(pattern, text); + expect(result, [11, 24, 80, 196, 324, 371]); + }); + + test('search algorithm returns empty list if pattern is not found', () { + const pattern = 'Flutter'; + const text = 'Welcome to Appflowy 😁'; + + final result = searchAlgorithm.searchMethod(pattern, text); + + expect(result, []); + }); + + test('search algorithm returns pattern index if pattern is non-ASCII', () { + const pattern = '😁'; + const text = 'Welcome to Appflowy 😁'; + + List result = searchAlgorithm.searchMethod(pattern, text); + expect(result, [20]); + }); + + test( + 'search algorithm returns pattern index if pattern is not separate word', + () { + const pattern = 'App'; + const text = 'Welcome to Appflowy 😁'; + + List result = searchAlgorithm.searchMethod(pattern, text); + expect(result, [11]); + }); + + test('search algorithm returns empty list bcz it is case sensitive', () { + const pattern = 'APPFLOWY'; + const text = 'Welcome to Appflowy 😁'; + + List result = searchAlgorithm.searchMethod(pattern, text); + expect(result, []); + }); + }); +} diff --git a/test/new/infra/testable_editor.dart b/test/new/infra/testable_editor.dart index 42bf2ac37..4e760b247 100644 --- a/test/new/infra/testable_editor.dart +++ b/test/new/infra/testable_editor.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../find_replace_menu/find_replace_menu_utils.dart'; import '../util/util.dart'; class TestableEditor { @@ -46,14 +47,24 @@ class TestableEditor { if (withFloatingToolbar) { scrollController ??= ScrollController(); } - Widget editor = AppFlowyEditor( - editorState: editorState, - editable: editable, - autoFocus: autoFocus, - shrinkWrap: shrinkWrap, - scrollController: scrollController, - editorStyle: - inMobile ? const EditorStyle.mobile() : const EditorStyle.desktop(), + Widget editor = Builder( + builder: (context) { + return AppFlowyEditor( + editorState: editorState, + editable: editable, + autoFocus: autoFocus, + shrinkWrap: shrinkWrap, + scrollController: scrollController, + commandShortcutEvents: [ + ...standardCommandShortcutEvents, + ...TestableFindAndReplaceCommands(context: context) + .testableFindAndReplaceCommands, + ], + editorStyle: inMobile + ? const EditorStyle.mobile() + : const EditorStyle.desktop(), + ); + }, ); if (withFloatingToolbar) { if (inMobile) {