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

* chore: update editor version

* feat: redesign find and replace ui

* chore: update language file
This commit is contained in:
Lucas.Xu 2023-10-02 13:54:51 +08:00 committed by GitHub
parent 0738b5f87d
commit c864e836ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 395 additions and 7 deletions

View File

@ -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,
),
),
);
},
),
);
}
}

View File

@ -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,
);
}
}

View File

@ -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';

View File

@ -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,

View File

@ -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,
),
);

View File

@ -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"

View File

@ -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

View File

@ -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>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 224 B

View File

@ -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"
}
}