mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
0738b5f87d
commit
c864e836ee
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
@ -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"
|
||||
|
@ -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
frontend/resources/flowy_icons/16x/arrow_down.svg
Normal file
3
frontend/resources/flowy_icons/16x/arrow_down.svg
Normal 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 |
3
frontend/resources/flowy_icons/16x/arrow_up.svg
Normal file
3
frontend/resources/flowy_icons/16x/arrow_up.svg
Normal 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 |
@ -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"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user