fix(flutter_desktop): document search (#6669)

* fix: double dispose on find menu

* fix: empty query not resetting search service

* fix: input focus getting lost after clicking button or pressing enter

* chore: remove unused focus node and text controller

* chore: bump appflowy editor

* chore: code cleanup

* chore: fix focus getting lost on submission

* fix: next match focuses on title after jumping

* chore: bump appflowy editor

* revert: unnecessary changes to FlowyFormTextInput

* fix: title requesting focus unexpectedly

* Update frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>

* chore: merge conflicts

* chore: code cleanup

* test: add integration test

* fix: show replace menu icon color in dark mode

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
This commit is contained in:
Richard Shiue 2024-11-14 17:02:34 +03:00 committed by GitHub
parent 1952ef0853
commit 941b7cf04c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 315 additions and 154 deletions

View File

@ -0,0 +1,144 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
String generateRandomString(int len) {
final r = Random();
return String.fromCharCodes(
List.generate(len, (index) => r.nextInt(33) + 89),
);
}
testWidgets(
'document find menu test',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent();
// tap editor to get focus
await tester.tapButton(find.byType(AppFlowyEditor));
// set clipboard data
final data = [
"123456\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
"1234567\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
"12345678\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
].join();
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: data,
),
);
// paste
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
// go back to beginning of document
// FIXME: Cannot run Ctrl+F unless selection is on screen
await tester.editor
.updateSelection(Selection.collapsed(Position(path: [0])));
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
// press cmd/ctrl+F to display the find menu
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyF,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
final textField = find.descendant(
of: find.byType(FindAndReplaceMenuWidget),
matching: find.byType(TextField),
);
await tester.enterText(
textField,
"123456",
);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("123456", findRichText: true),
),
findsOneWidget,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("1234567", findRichText: true),
),
findsOneWidget,
);
await tester.showKeyboard(textField);
await tester.idle();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("12345678", findRichText: true),
),
findsOneWidget,
);
// tap next button, go back to beginning of document
await tester.tapButton(
find.descendant(
of: find.byType(FindMenu),
matching: find.byFlowySvg(FlowySvgs.arrow_down_s),
),
);
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("123456", findRichText: true),
),
findsOneWidget,
);
},
);
}

View File

@ -1,6 +1,7 @@
import 'package:integration_test/integration_test.dart';
import 'document_block_option_test.dart' as document_block_option_test;
import 'document_find_menu_test.dart' as document_find_menu_test;
import 'document_inline_page_reference_test.dart'
as document_inline_page_reference_test;
import 'document_more_actions_test.dart' as document_more_actions_test;
@ -22,5 +23,6 @@ void main() {
document_with_file_test.main();
document_shortcuts_test.main();
document_block_option_test.main();
document_find_menu_test.main();
document_toolbar_test.main();
}

View File

@ -161,7 +161,16 @@ class _DocumentPageState extends State<DocumentPage>
}
return Provider(
create: (_) => SharedEditorContext(),
create: (_) {
final context = SharedEditorContext();
if (widget.view.name.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.coverTitleFocusNode.requestFocus();
});
}
return context;
},
dispose: (buildContext, editorContext) => editorContext.dispose(),
child: EditorTransactionService(
viewId: widget.view.id,
editorState: state.editorState!,

View File

@ -485,6 +485,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
borderRadius: BorderRadius.circular(4),
),
child: FindAndReplaceMenuWidget(
showReplaceMenu: showReplaceMenu,
editorState: editorState,
onDismiss: onDismiss,
),

View File

@ -1,37 +1,62 @@
import 'package:flutter/material.dart';
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';
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,
required this.showReplaceMenu,
});
final EditorState editorState;
final VoidCallback onDismiss;
/// Whether to show the replace menu initially
final bool showReplaceMenu;
@override
State<FindAndReplaceMenuWidget> createState() =>
_FindAndReplaceMenuWidgetState();
}
class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
bool showReplaceMenu = false;
late bool showReplaceMenu = widget.showReplaceMenu;
final findFocusNode = FocusNode();
final replaceFocusNode = FocusNode();
late SearchServiceV3 searchService = SearchServiceV3(
editorState: widget.editorState,
);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.showReplaceMenu) {
replaceFocusNode.requestFocus();
} else {
findFocusNode.requestFocus();
}
});
}
@override
void dispose() {
findFocusNode.dispose();
replaceFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
return TextFieldTapRegion(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
@ -40,23 +65,26 @@ class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
onDismiss: widget.onDismiss,
editorState: widget.editorState,
searchService: searchService,
onShowReplace: (value) => setState(
() => showReplaceMenu = value,
focusNode: findFocusNode,
showReplaceMenu: showReplaceMenu,
onToggleShowReplace: () => setState(() {
showReplaceMenu = !showReplaceMenu;
}),
),
),
),
showReplaceMenu
? Padding(
if (showReplaceMenu)
Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
),
child: ReplaceMenu(
editorState: widget.editorState,
searchService: searchService,
focusNode: replaceFocusNode,
),
),
)
: const SizedBox.shrink(),
],
),
);
}
}
@ -64,29 +92,30 @@ class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
class FindMenu extends StatefulWidget {
const FindMenu({
super.key,
required this.onDismiss,
required this.editorState,
required this.searchService,
required this.onShowReplace,
required this.showReplaceMenu,
required this.focusNode,
required this.onDismiss,
required this.onToggleShowReplace,
});
final EditorState editorState;
final VoidCallback onDismiss;
final SearchServiceV3 searchService;
final void Function(bool value) onShowReplace;
final bool showReplaceMenu;
final FocusNode focusNode;
final VoidCallback onDismiss;
final void Function() onToggleShowReplace;
@override
State<FindMenu> createState() => _FindMenuState();
}
class _FindMenuState extends State<FindMenu> {
late final FocusNode findTextFieldFocusNode;
final textController = TextEditingController();
final findTextEditingController = TextEditingController();
String queriedPattern = '';
bool showReplaceMenu = false;
bool caseSensitive = false;
@override
@ -96,11 +125,7 @@ class _FindMenuState extends State<FindMenu> {
widget.searchService.matchWrappers.addListener(_setState);
widget.searchService.currentSelectedIndex.addListener(_setState);
findTextEditingController.addListener(_searchPattern);
WidgetsBinding.instance.addPostFrameCallback((_) {
findTextFieldFocusNode.requestFocus();
});
textController.addListener(_searchPattern);
}
@override
@ -108,9 +133,7 @@ class _FindMenuState extends State<FindMenu> {
widget.searchService.matchWrappers.removeListener(_setState);
widget.searchService.currentSelectedIndex.removeListener(_setState);
widget.searchService.dispose();
findTextEditingController.removeListener(_searchPattern);
findTextEditingController.dispose();
findTextFieldFocusNode.dispose();
textController.dispose();
super.dispose();
}
@ -124,42 +147,36 @@ class _FindMenuState extends State<FindMenu> {
const HSpace(4.0),
// expand/collapse button
_FindAndReplaceIcon(
icon: showReplaceMenu
icon: widget.showReplaceMenu
? FlowySvgs.drop_menu_show_s
: FlowySvgs.drop_menu_hide_s,
tooltipText: '',
onPressed: () {
widget.onShowReplace(!showReplaceMenu);
setState(
() => showReplaceMenu = !showReplaceMenu,
);
},
onPressed: widget.onToggleShowReplace,
),
const HSpace(4.0),
// find text input
SizedBox(
width: 150,
width: 200,
height: 30,
child: FlowyFormTextInput(
onFocusCreated: (focusNode) {
findTextFieldFocusNode = focusNode;
},
onEditingComplete: () {
child: TextField(
key: const Key('findTextField'),
focusNode: widget.focusNode,
controller: textController,
style: Theme.of(context).textTheme.bodyMedium,
onSubmitted: (_) {
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), () {
if (context.mounted) {
FocusScope.of(context).requestFocus(
findTextFieldFocusNode,
// focus back to the text field
Future.delayed(
const Duration(milliseconds: 50),
() => widget.focusNode.requestFocus(),
);
}
});
},
controller: findTextEditingController,
hintText: LocaleKeys.findAndReplace_find.tr(),
textAlign: TextAlign.left,
decoration: _buildInputDecoration(
LocaleKeys.findAndReplace_find.tr(),
),
),
),
// the count of matches
@ -210,11 +227,8 @@ class _FindMenuState extends State<FindMenu> {
}
void _searchPattern() {
if (findTextEditingController.text.isEmpty) {
return;
}
widget.searchService.findAndHighlight(findTextEditingController.text);
setState(() => queriedPattern = findTextEditingController.text);
widget.searchService.findAndHighlight(textController.text);
_setState();
}
void _setState() {
@ -227,27 +241,24 @@ class ReplaceMenu extends StatefulWidget {
super.key,
required this.editorState,
required this.searchService,
this.localizations,
required this.focusNode,
});
final EditorState editorState;
/// The localizations of the find and replace menu
final FindReplaceLocalizations? localizations;
final SearchServiceV3 searchService;
final FocusNode focusNode;
@override
State<ReplaceMenu> createState() => _ReplaceMenuState();
}
class _ReplaceMenuState extends State<ReplaceMenu> {
late final FocusNode replaceTextFieldFocusNode;
final replaceTextEditingController = TextEditingController();
final textController = TextEditingController();
@override
void dispose() {
replaceTextEditingController.dispose();
textController.dispose();
super.dispose();
}
@ -258,31 +269,26 @@ class _ReplaceMenuState extends State<ReplaceMenu> {
// placeholder for aligning the replace menu
const HSpace(30),
SizedBox(
width: 150,
width: 200,
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), () {
if (context.mounted) {
FocusScope.of(context).requestFocus(
replaceTextFieldFocusNode,
child: TextField(
key: const Key('replaceTextField'),
focusNode: widget.focusNode,
controller: textController,
style: Theme.of(context).textTheme.bodyMedium,
onSubmitted: (_) {
_replaceSelectedWord();
Future.delayed(
const Duration(milliseconds: 50),
() => widget.focusNode.requestFocus(),
);
}
});
},
controller: replaceTextEditingController,
hintText: LocaleKeys.findAndReplace_replace.tr(),
textAlign: TextAlign.left,
decoration: _buildInputDecoration(
LocaleKeys.findAndReplace_replace.tr(),
),
),
),
const HSpace(4.0),
_FindAndReplaceIcon(
onPressed: _replaceSelectedWord,
iconBuilder: (_) => const Icon(
@ -299,7 +305,7 @@ class _ReplaceMenuState extends State<ReplaceMenu> {
),
tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(),
onPressed: () => widget.searchService.replaceAllMatches(
replaceTextEditingController.text,
textController.text,
),
),
],
@ -307,7 +313,7 @@ class _ReplaceMenuState extends State<ReplaceMenu> {
}
void _replaceSelectedWord() {
widget.searchService.replaceSelectedWord(replaceTextEditingController.text);
widget.searchService.replaceSelectedWord(textController.text);
}
}
@ -333,10 +339,20 @@ class _FindAndReplaceIcon extends StatelessWidget {
height: 24,
onPressed: onPressed,
icon: iconBuilder?.call(context) ??
(icon != null ? FlowySvg(icon!) : const Placeholder()),
(icon != null
? FlowySvg(icon!, color: Theme.of(context).iconTheme.color)
: const Placeholder()),
tooltipText: tooltipText,
isSelected: isSelected,
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
);
}
}
InputDecoration _buildInputDecoration(String hintText) {
return InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
border: const UnderlineInputBorder(),
hintText: hintText,
);
}

View File

@ -45,11 +45,10 @@ class _InnerCoverTitle extends StatefulWidget {
class _InnerCoverTitleState extends State<_InnerCoverTitle> {
final titleTextController = TextEditingController();
final titleFocusNode = FocusNode();
late final editorContext = context.read<SharedEditorContext>();
late final editorState = context.read<EditorState>();
bool isTitleFocused = false;
late final titleFocusNode = editorContext.coverTitleFocusNode;
int lineCount = 1;
@override
@ -58,53 +57,32 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
titleTextController.text = widget.view.name;
titleTextController.addListener(_onViewNameChanged);
titleFocusNode.onKeyEvent = _onKeyEvent;
titleFocusNode.addListener(_onTitleFocusChanged);
titleFocusNode
..onKeyEvent = _onKeyEvent
..addListener(_onFocusChanged);
editorState.selectionNotifier.addListener(_onSelectionChanged);
_requestFocusIfNeeded(widget.view, null);
editorContext.coverTitleFocusNode = titleFocusNode;
}
@override
void dispose() {
editorContext.coverTitleFocusNode = null;
editorState.selectionNotifier.removeListener(_onSelectionChanged);
titleTextController.removeListener(_onViewNameChanged);
titleFocusNode
..onKeyEvent = null
..removeListener(_onFocusChanged);
titleTextController.dispose();
titleFocusNode.removeListener(_onTitleFocusChanged);
titleFocusNode.dispose();
editorState.selectionNotifier.removeListener(_onSelectionChanged);
super.dispose();
}
void _onSelectionChanged() {
// if title is focused and the selection is not null, clear the selection
if (editorState.selection != null && isTitleFocused) {
if (editorState.selection != null && titleFocusNode.hasFocus) {
Log.info('title is focused, clear the editor selection');
editorState.selection = null;
}
}
void _onTitleFocusChanged() {
isTitleFocused = titleFocusNode.hasFocus;
if (titleFocusNode.hasFocus && editorState.selection != null) {
Log.info('cover title got focus, clear the editor selection');
editorState.selection = null;
}
if (isTitleFocused) {
Log.info('cover title got focus, disable keyboard service');
editorState.service.keyboardService?.disable();
} else {
Log.info('cover title lost focus, enable keyboard service');
editorState.service.keyboardService?.enable();
}
}
@override
Widget build(BuildContext context) {
final fontStyle = Theme.of(context)
@ -175,6 +153,21 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
}
}
void _onFocusChanged() {
if (titleFocusNode.hasFocus) {
if (editorState.selection != null) {
Log.info('cover title got focus, clear the editor selection');
editorState.selection = null;
}
Log.info('cover title got focus, disable keyboard service');
editorState.service.keyboardService?.disable();
} else {
Log.info('cover title lost focus, enable keyboard service');
editorState.service.keyboardService?.enable();
}
}
void _onViewNameChanged() {
Debounce.debounce(
'update view name',

View File

@ -103,8 +103,6 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
late final ViewListener viewListener;
int retryCount = 0;
final titleTextController = TextEditingController();
final titleFocusNode = FocusNode();
final isCoverTitleHovered = ValueNotifier<bool>(false);
late final gestureInterceptor = SelectionGestureInterceptor(
@ -120,7 +118,6 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
viewIcon = value.isNotEmpty ? value : icon ?? '';
cover = widget.view.cover;
view = widget.view;
titleTextController.text = view.name;
widget.node.addListener(_reload);
widget.editorState.service.selectionService
.registerGestureInterceptor(gestureInterceptor);
@ -128,9 +125,6 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
viewListener = ViewListener(viewId: widget.view.id)
..start(
onViewUpdated: (view) {
if (titleTextController.text != view.name) {
titleTextController.text = view.name;
}
setState(() {
viewIcon = view.icon.value;
cover = view.cover;
@ -144,8 +138,6 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
void dispose() {
viewListener.stop();
widget.node.removeListener(_reload);
titleTextController.dispose();
titleFocusNode.dispose();
isCoverTitleHovered.dispose();
widget.editorState.service.selectionService
.unregisterGestureInterceptor(_interceptorKey);

View File

@ -6,9 +6,14 @@ import 'package:flutter/widgets.dart';
/// so we need to use the shared context to get the focus node.
///
class SharedEditorContext {
SharedEditorContext();
SharedEditorContext() : _coverTitleFocusNode = FocusNode();
// The focus node of the cover title.
// It's null when the cover title is not focused.
FocusNode? coverTitleFocusNode;
final FocusNode _coverTitleFocusNode;
FocusNode get coverTitleFocusNode => _coverTitleFocusNode;
void dispose() {
_coverTitleFocusNode.dispose();
}
}

View File

@ -7,8 +7,7 @@ import 'package:flutter/services.dart';
import 'package:flowy_infra/size.dart';
class FlowyFormTextInput extends StatelessWidget {
static EdgeInsets kDefaultTextInputPadding =
EdgeInsets.only(bottom: Insets.sm, top: 4);
static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2);
final String? label;
final bool? autoFocus;