From c864e836ee0cfeeea43ca83dec9dfb3003daab6d Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 2 Oct 2023 13:54:51 +0800 Subject: [PATCH] feat: integrate find and replace into AppFlowy (#3566) * chore: update editor version * feat: redesign find and replace ui * chore: update language file --- .../document/presentation/editor_page.dart | 39 ++- .../find_and_replace_menu.dart | 331 ++++++++++++++++++ .../presentation/editor_plugins/plugins.dart | 1 + .../lib/style_widget/icon_button.dart | 3 + .../lib/style_widget/text_input.dart | 6 +- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- .../resources/flowy_icons/16x/arrow_down.svg | 3 + .../resources/flowy_icons/16x/arrow_up.svg | 3 + frontend/resources/translations/en.json | 10 + 10 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart create mode 100644 frontend/resources/flowy_icons/16x/arrow_down.svg create mode 100644 frontend/resources/flowy_icons/16x/arrow_up.svg diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 5114103270..1f8f9ad6a3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -48,13 +48,14 @@ class _AppFlowyEditorPageState extends State { final inlinePageReferenceService = InlinePageReferenceService(); - final List commandShortcutEvents = [ + late final List commandShortcutEvents = [ toggleToggleListCommand, ...codeBlockCommands, customCopyCommand, customPasteCommand, customCutCommand, ...standardCommandShortcutEvents, + ..._buildFindAndReplaceCommands(), ]; final List toolbarItems = [ @@ -147,6 +148,8 @@ class _AppFlowyEditorPageState extends State { effectiveScrollController.dispose(); } + widget.editorState.dispose(); + super.dispose(); } @@ -158,7 +161,7 @@ class _AppFlowyEditorPageState extends State { final isRTL = context.read().state.layoutDirection == LayoutDirection.rtlLayout; - final layoutDirection = isRTL ? TextDirection.rtl : TextDirection.ltr; + final textDirection = isRTL ? TextDirection.rtl : TextDirection.ltr; _setRTLToolbarItems(isRTL); @@ -195,8 +198,9 @@ class _AppFlowyEditorPageState extends State { items: toolbarItems, editorState: widget.editorState, editorScrollController: editorScrollController, + textDirection: textDirection, child: Directionality( - textDirection: layoutDirection, + textDirection: textDirection, child: editor, ), ), @@ -480,4 +484,33 @@ class _AppFlowyEditorPageState extends State { toolbarItems.addAll(textDirectionItems); } } + + List _buildFindAndReplaceCommands() { + return findAndReplaceCommands( + context: context, + style: FindReplaceStyle( + findMenuBuilder: ( + context, + editorState, + localizations, + style, + showReplaceMenu, + onDismiss, + ) { + return Material( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + child: FindAndReplaceMenuWidget( + editorState: editorState, + onDismiss: onDismiss, + ), + ), + ); + }, + ), + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart new file mode 100644 index 0000000000..aded39f614 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart @@ -0,0 +1,331 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; +import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flutter/material.dart'; + +class FindAndReplaceMenuWidget extends StatefulWidget { + const FindAndReplaceMenuWidget({ + super.key, + required this.onDismiss, + required this.editorState, + }); + + final EditorState editorState; + final VoidCallback onDismiss; + + @override + State createState() => + _FindAndReplaceMenuWidgetState(); +} + +class _FindAndReplaceMenuWidgetState extends State { + bool showReplaceMenu = false; + + late SearchServiceV2 searchService = SearchServiceV2( + editorState: widget.editorState, + ); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FindMenu( + onDismiss: widget.onDismiss, + editorState: widget.editorState, + searchService: searchService, + onShowReplace: (value) => setState( + () => showReplaceMenu = value, + ), + ), + ), + showReplaceMenu + ? Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: ReplaceMenu( + editorState: widget.editorState, + searchService: searchService, + ), + ) + : const SizedBox.shrink(), + ], + ); + } +} + +class FindMenu extends StatefulWidget { + const FindMenu({ + super.key, + required this.onDismiss, + required this.editorState, + required this.searchService, + required this.onShowReplace, + }); + + final EditorState editorState; + final VoidCallback onDismiss; + final SearchServiceV2 searchService; + final void Function(bool value) onShowReplace; + + @override + State createState() => _FindMenuState(); +} + +class _FindMenuState extends State { + late final FocusNode findTextFieldFocusNode; + + final findTextEditingController = TextEditingController(); + + String queriedPattern = ''; + + bool showReplaceMenu = false; + bool caseSensitive = false; + + @override + void initState() { + super.initState(); + + widget.searchService.matchedPositions.addListener(_setState); + widget.searchService.currentSelectedIndex.addListener(_setState); + + findTextEditingController.addListener(_searchPattern); + + WidgetsBinding.instance.addPostFrameCallback((_) { + findTextFieldFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + widget.searchService.matchedPositions.removeListener(_setState); + widget.searchService.currentSelectedIndex.removeListener(_setState); + widget.searchService.dispose(); + findTextEditingController.removeListener(_searchPattern); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // the selectedIndex from searchService is 0-based + final selectedIndex = widget.searchService.selectedIndex + 1; + final matches = widget.searchService.matchedPositions.value; + return Row( + children: [ + const HSpace(4.0), + // expand/collapse button + _FindAndReplaceIcon( + icon: showReplaceMenu + ? FlowySvgs.drop_menu_show_s + : FlowySvgs.drop_menu_hide_s, + tooltipText: '', + onPressed: () { + widget.onShowReplace(!showReplaceMenu); + setState( + () => showReplaceMenu = !showReplaceMenu, + ); + }, + ), + const HSpace(4.0), + // find text input + SizedBox( + width: 150, + height: 30, + child: FlowyFormTextInput( + onFocusCreated: (focusNode) { + findTextFieldFocusNode = focusNode; + }, + onEditingComplete: () { + widget.searchService.navigateToMatch(); + // after update selection or navigate to match, the editor + // will request focus, here's a workaround to request the + // focus back to the findTextField + Future.delayed(const Duration(milliseconds: 50), () { + FocusScope.of(context).requestFocus( + findTextFieldFocusNode, + ); + }); + }, + controller: findTextEditingController, + hintText: LocaleKeys.findAndReplace_find.tr(), + textAlign: TextAlign.left, + ), + ), + // the count of matches + Container( + constraints: const BoxConstraints(minWidth: 80), + padding: const EdgeInsets.symmetric(horizontal: 8.0), + alignment: Alignment.centerLeft, + child: FlowyText( + matches.isEmpty + ? LocaleKeys.findAndReplace_noResult.tr() + : '$selectedIndex of ${matches.length}', + ), + ), + const HSpace(4.0), + // case sensitive button + _FindAndReplaceIcon( + icon: FlowySvgs.text_s, + tooltipText: LocaleKeys.findAndReplace_caseSensitive.tr(), + onPressed: () => setState(() { + caseSensitive = !caseSensitive; + widget.searchService.caseSensitive = caseSensitive; + }), + isSelected: caseSensitive, + ), + const HSpace(4.0), + // previous match button + _FindAndReplaceIcon( + onPressed: () => widget.searchService.navigateToMatch(moveUp: true), + icon: FlowySvgs.arrow_up_s, + tooltipText: LocaleKeys.findAndReplace_previousMatch.tr(), + ), + const HSpace(4.0), + // next match button + _FindAndReplaceIcon( + onPressed: () => widget.searchService.navigateToMatch(), + icon: FlowySvgs.arrow_down_s, + tooltipText: LocaleKeys.findAndReplace_nextMatch.tr(), + ), + const HSpace(4.0), + _FindAndReplaceIcon( + onPressed: widget.onDismiss, + icon: FlowySvgs.close_s, + tooltipText: LocaleKeys.findAndReplace_close.tr(), + ), + const HSpace(4.0), + ], + ); + } + + void _searchPattern() { + if (findTextEditingController.text.isEmpty) { + return; + } + widget.searchService.findAndHighlight(findTextEditingController.text); + setState(() => queriedPattern = findTextEditingController.text); + } + + void _setState() { + setState(() {}); + } +} + +class ReplaceMenu extends StatefulWidget { + const ReplaceMenu({ + super.key, + required this.editorState, + required this.searchService, + this.localizations, + }); + + final EditorState editorState; + + /// The localizations of the find and replace menu + final FindReplaceLocalizations? localizations; + + final SearchServiceV2 searchService; + + @override + State createState() => _ReplaceMenuState(); +} + +class _ReplaceMenuState extends State { + late final FocusNode replaceTextFieldFocusNode; + final replaceTextEditingController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // placeholder for aligning the replace menu + const HSpace(30), + SizedBox( + width: 150, + height: 30, + child: FlowyFormTextInput( + onFocusCreated: (focusNode) { + replaceTextFieldFocusNode = focusNode; + }, + onEditingComplete: () { + widget.searchService.navigateToMatch(); + // after update selection or navigate to match, the editor + // will request focus, here's a workaround to request the + // focus back to the findTextField + Future.delayed(const Duration(milliseconds: 50), () { + FocusScope.of(context).requestFocus( + replaceTextFieldFocusNode, + ); + }); + }, + controller: replaceTextEditingController, + hintText: LocaleKeys.findAndReplace_replace.tr(), + textAlign: TextAlign.left, + ), + ), + const HSpace(4.0), + _FindAndReplaceIcon( + onPressed: _replaceSelectedWord, + iconBuilder: (_) => const Icon( + Icons.find_replace_outlined, + size: 16, + ), + tooltipText: LocaleKeys.findAndReplace_replace.tr(), + ), + const HSpace(4.0), + _FindAndReplaceIcon( + iconBuilder: (_) => const Icon( + Icons.change_circle_outlined, + size: 16, + ), + tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(), + onPressed: () => widget.searchService.replaceAllMatches( + replaceTextEditingController.text, + ), + ), + ], + ); + } + + void _replaceSelectedWord() { + widget.searchService.replaceSelectedWord(replaceTextEditingController.text); + } +} + +class _FindAndReplaceIcon extends StatelessWidget { + const _FindAndReplaceIcon({ + required this.onPressed, + required this.tooltipText, + this.icon, + this.iconBuilder, + this.isSelected, + }); + + final VoidCallback onPressed; + final FlowySvgData? icon; + final WidgetBuilder? iconBuilder; + final String tooltipText; + final bool? isSelected; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: 24, + height: 24, + onPressed: onPressed, + icon: iconBuilder?.call(context) ?? + (icon != null ? FlowySvg(icon!) : const Placeholder()), + tooltipText: tooltipText, + isSelected: isSelected, + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index c0b9c98e52..435d35f42e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -14,6 +14,7 @@ export 'database/inline_database_menu_item.dart'; export 'database/referenced_database_menu_item.dart'; export 'emoji_picker/emoji_menu_item.dart'; export 'extensions/flowy_tint_extension.dart'; +export 'find_and_replace/find_and_replace_menu.dart'; export 'font/customize_font_toolbar_item.dart'; export 'header/cover_editor_bloc.dart'; export 'header/custom_cover_picker.dart'; diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index e3ab209406..31c370b99d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -17,6 +17,7 @@ class FlowyIconButton extends StatelessWidget { final InlineSpan? richTooltipText; final bool preferBelow; final BoxDecoration? decoration; + final bool? isSelected; const FlowyIconButton({ Key? key, @@ -32,6 +33,7 @@ class FlowyIconButton extends StatelessWidget { this.tooltipText, this.richTooltipText, this.preferBelow = true, + this.isSelected, required this.icon, }) : assert((richTooltipText != null && tooltipText == null) || (richTooltipText == null && tooltipText != null) || @@ -74,6 +76,7 @@ class FlowyIconButton extends StatelessWidget { elevation: 0, onPressed: onPressed, child: FlowyHover( + isSelected: isSelected != null ? () => isSelected! : null, style: HoverStyle( // hoverColor is set in both [HoverStyle] and [RawMaterialButton] to avoid the conflicts between two layers hoverColor: hoverColor, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart index f7db45d8b2..c7e6368aa8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; + import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -61,7 +62,10 @@ class FlowyFormTextInput extends StatelessWidget { contentPadding: contentPadding ?? kDefaultTextInputPadding, border: const ThinUnderlineBorder( borderSide: BorderSide(width: 5, color: Colors.red)), - //focusedBorder: UnderlineInputBorder(borderSide: BorderSide(width: .5, color: Colors.red)), + hintStyle: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).hintColor.withOpacity(0.7)), hintText: hintText, ), ); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index fd1cc2085d..f8899d7954 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -54,8 +54,8 @@ packages: dependency: "direct main" description: path: "." - ref: b422187 - resolved-ref: b422187503fc99067756e9f387766bb28475331b + ref: "0fdca2f" + resolved-ref: "0fdca2f702485eeec1bfbe50127c06f2a8fd8b1e" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "1.4.3" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 9102cf9420..c4f8bad814 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -47,7 +47,7 @@ dependencies: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: b422187 + ref: '0fdca2f' appflowy_popover: path: packages/appflowy_popover diff --git a/frontend/resources/flowy_icons/16x/arrow_down.svg b/frontend/resources/flowy_icons/16x/arrow_down.svg new file mode 100644 index 0000000000..d011270c23 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/arrow_up.svg b/frontend/resources/flowy_icons/16x/arrow_up.svg new file mode 100644 index 0000000000..6fdb0323bf --- /dev/null +++ b/frontend/resources/flowy_icons/16x/arrow_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 890638adbd..40369ee438 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -754,5 +754,15 @@ "nature": "Nature", "frequentlyUsed": "Frequently Used" } + }, + "findAndReplace": { + "find": "Find", + "previousMatch": "Previous match", + "nextMatch": "Next match", + "close": "Close", + "replace": "Replace", + "replaceAll": "Replace all", + "noResult": "No results", + "caseSensitive": "Case sensitive" } } \ No newline at end of file