Ver código fonte

feat: integrate find and replace into AppFlowy (#3566)

* chore: update editor version

* feat: redesign find and replace ui

* chore: update language file
Lucas.Xu 1 ano atrás
pai
commit
c864e836ee

+ 36 - 3
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -48,13 +48,14 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
 
   final inlinePageReferenceService = InlinePageReferenceService();
 
-  final List<CommandShortcutEvent> commandShortcutEvents = [
+  late final List<CommandShortcutEvent> commandShortcutEvents = [
     toggleToggleListCommand,
     ...codeBlockCommands,
     customCopyCommand,
     customPasteCommand,
     customCutCommand,
     ...standardCommandShortcutEvents,
+    ..._buildFindAndReplaceCommands(),
   ];
 
   final List<ToolbarItem> toolbarItems = [
@@ -147,6 +148,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
       effectiveScrollController.dispose();
     }
 
+    widget.editorState.dispose();
+
     super.dispose();
   }
 
@@ -158,7 +161,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
     final isRTL =
         context.read<AppearanceSettingsCubit>().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<AppFlowyEditorPage> {
         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<AppFlowyEditorPage> {
       toolbarItems.addAll(textDirectionItems);
     }
   }
+
+  List<CommandShortcutEvent> _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,
+              ),
+            ),
+          );
+        },
+      ),
+    );
+  }
 }

+ 331 - 0
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<FindAndReplaceMenuWidget> createState() =>
+      _FindAndReplaceMenuWidgetState();
+}
+
+class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
+  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<FindMenu> createState() => _FindMenuState();
+}
+
+class _FindMenuState extends State<FindMenu> {
+  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<ReplaceMenu> createState() => _ReplaceMenuState();
+}
+
+class _ReplaceMenuState extends State<ReplaceMenu> {
+  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,
+    );
+  }
+}

+ 1 - 0
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';

+ 3 - 0
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,

+ 5 - 1
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,
       ),
     );

+ 2 - 2
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"

+ 1 - 1
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
 

+ 3 - 0
frontend/resources/flowy_icons/16x/arrow_down.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 4L6 8L10 12" transform="rotate(270, 8, 8)" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
frontend/resources/flowy_icons/16x/arrow_up.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 4L6 8L10 12" transform="rotate(90, 8, 8)" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 10 - 0
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"
   }
 }