feat: optimize the find and replace logic (#510)

This commit is contained in:
Lucas.Xu 2023-10-01 16:51:42 +08:00 • committed by GitHub
commit 384532434c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 638 additions and 273 deletions

View file

@ -39,6 +39,14 @@ class _DesktopEditorState extends State<DesktopEditor> {
commandShortcuts = _buildCommandShortcuts(); commandShortcuts = _buildCommandShortcuts();
} }
@override
void dispose() {
editorScrollController.dispose();
editorState.dispose();
super.dispose();
}
@override @override
void reassemble() { void reassemble() {
super.reassemble(); super.reassemble();
@ -174,6 +182,7 @@ class _DesktopEditorState extends State<DesktopEditor> {
close: 'Close', close: 'Close',
replace: 'Replace', replace: 'Replace',
replaceAll: 'Replace all', replaceAll: 'Replace all',
noResult: 'No result',
), ),
), ),
]; ];

View file

@ -103,12 +103,6 @@ class _ScrollServiceWidgetState extends State<ScrollServiceWidget>
final endTouchPoint = selectionRect.last.centerRight; final endTouchPoint = selectionRect.last.centerRight;
if (editorState.selectionUpdateReason ==
SelectionUpdateReason.searchNavigate) {
return widget.editorScrollController
.jumpTo(offset: endTouchPoint.dy - 100);
}
if (PlatformExtension.isMobile) { if (PlatformExtension.isMobile) {
// soft keyboard // soft keyboard
// workaround: wait for the soft keyboard to show up // workaround: wait for the soft keyboard to show up

View file

@ -40,6 +40,7 @@ class FindReplaceLocalizations {
required this.close, required this.close,
required this.replace, required this.replace,
required this.replaceAll, required this.replaceAll,
required this.noResult,
}); });
final String find; final String find;
@ -48,6 +49,7 @@ class FindReplaceLocalizations {
final String close; final String close;
final String replace; final String replace;
final String replaceAll; final String replaceAll;
final String noResult;
} }
/// Show the slash menu /// Show the slash menu

View file

@ -36,6 +36,8 @@ class FindReplaceMenu implements FindReplaceService {
editorState.service.scrollService?.enable(); editorState.service.scrollService?.enable();
} }
editorState.onDispose.removeListener(dismiss);
_findReplaceMenuEntry?.remove(); _findReplaceMenuEntry?.remove();
_findReplaceMenuEntry = null; _findReplaceMenuEntry = null;
@ -59,6 +61,8 @@ class FindReplaceMenu implements FindReplaceService {
return; return;
} }
editorState.onDispose.addListener(dismiss);
_findReplaceMenuEntry = OverlayEntry( _findReplaceMenuEntry = OverlayEntry(
builder: (context) { builder: (context) {
return Positioned( return Positioned(
@ -78,10 +82,10 @@ class FindReplaceMenu implements FindReplaceService {
], ],
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
), ),
child: FindMenuWidget( child: FindAndReplaceMenuWidget(
dismiss: dismiss, onDismiss: dismiss,
editorState: editorState, editorState: editorState,
replaceFlag: replaceFlag, showReplaceMenu: replaceFlag,
localizations: localizations, localizations: localizations,
style: style, style: style,
), ),

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
const double _iconButtonSize = 30;
const double _iconSize = 15;
class FindAndReplaceMenuIconButton extends StatelessWidget {
const FindAndReplaceMenuIconButton({
super.key,
required this.icon,
this.onPressed,
this.iconSize,
this.tooltip,
this.iconButtonKey,
});
final Widget icon;
final VoidCallback? onPressed;
final double? iconSize;
final String? tooltip;
final Key? iconButtonKey;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _iconButtonSize,
height: _iconButtonSize,
child: IconButton(
key: iconButtonKey,
onPressed: onPressed,
icon: icon,
iconSize: iconSize ?? _iconSize,
tooltip: tooltip,
),
);
}
}

View file

@ -1,237 +1,374 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/search_service.dart'; import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_menu_icon_button.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/search_service_v2.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const double _iconSize = 20; const double _iconButtonSize = 30;
const double _iconButtonSize = 40;
class FindMenuWidget extends StatefulWidget { class FindAndReplaceMenuWidget extends StatefulWidget {
const FindMenuWidget({ const FindAndReplaceMenuWidget({
super.key, super.key,
required this.dismiss,
required this.editorState,
required this.replaceFlag,
this.localizations, this.localizations,
this.defaultFindText = '',
this.caseSensitive = false,
required this.onDismiss,
required this.editorState,
required this.showReplaceMenu,
required this.style, required this.style,
}); });
final VoidCallback dismiss;
final EditorState editorState; final EditorState editorState;
final bool replaceFlag; final VoidCallback onDismiss;
final FindReplaceLocalizations? localizations;
/// Whether to show the replace menu or not
final bool showReplaceMenu;
/// The style of the find and replace menu
///
/// only works for SearchService, not for SearchServiceV2
final FindReplaceStyle style; final FindReplaceStyle style;
/// The localizations of the find and replace menu
final FindReplaceLocalizations? localizations;
/// The default text to search for
final String defaultFindText;
/// Whether the search should be case sensitive or not
final bool caseSensitive;
@override @override
State<FindMenuWidget> createState() => _FindMenuWidgetState(); State<FindAndReplaceMenuWidget> createState() =>
_FindAndReplaceMenuWidgetState();
} }
class _FindMenuWidgetState extends State<FindMenuWidget> { class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
final focusNode = FocusNode(); bool showReplaceMenu = false;
final replaceFocusNode = FocusNode();
final findController = TextEditingController(); late SearchServiceV2 searchService = SearchServiceV2(
final replaceController = TextEditingController(); editorState: widget.editorState,
String queriedPattern = ''; );
bool replaceFlag = false;
late SearchService searchService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
replaceFlag = widget.replaceFlag;
searchService = SearchService(
editorState: widget.editorState,
style: SearchStyle(
selectedHighlightColor: widget.style.selectedHighlightColor,
unselectedHighlightColor: widget.style.unselectedHighlightColor,
),
);
WidgetsBinding.instance.addPostFrameCallback((_) { showReplaceMenu = widget.showReplaceMenu;
focusNode.requestFocus();
});
findController.addListener(_searchPattern);
}
@override
void dispose() {
findController.removeListener(_searchPattern);
focusNode.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Padding(
children: [ padding: const EdgeInsets.symmetric(vertical: 8.0),
IconButton( child: FindMenu(
onPressed: () => setState( onDismiss: widget.onDismiss,
() => replaceFlag = !replaceFlag, editorState: widget.editorState,
), style: widget.style,
icon: replaceFlag searchService: searchService,
? const Icon(Icons.expand_less) defaultFindText: widget.defaultFindText,
: const Icon(Icons.expand_more), caseSensitive: widget.caseSensitive,
), localizations: widget.localizations,
SizedBox( showReplaceMenu: showReplaceMenu,
width: 200, onShowReplace: (value) => setState(() {
height: 30, showReplaceMenu = value;
child: TextField( }),
key: const Key('findTextField'), ),
focusNode: focusNode,
controller: findController,
onSubmitted: (_) {
searchService.navigateToMatch();
// Workaround for editor forcing focus
Future.delayed(const Duration(milliseconds: 50)).then(
(value) => FocusScope.of(context).requestFocus(focusNode),
);
},
decoration: _buildInputDecoration(
widget.localizations?.find ??
AppFlowyEditorLocalizations.current.find,
),
),
),
FindMenuIconButton(
buttonKey: const Key('previousMatchButton'),
iconSize: _iconSize,
onPressed: () => searchService.navigateToMatch(moveUp: true),
icon: const Icon(Icons.arrow_upward),
tooltip: widget.localizations?.previousMatch ??
AppFlowyEditorLocalizations.current.previousMatch,
),
FindMenuIconButton(
buttonKey: const Key('nextMatchButton'),
iconSize: _iconSize,
onPressed: () => searchService.navigateToMatch(),
icon: const Icon(Icons.arrow_downward),
tooltip: widget.localizations?.nextMatch ??
AppFlowyEditorLocalizations.current.nextMatch,
),
FindMenuIconButton(
buttonKey: const Key('closeButton'),
iconSize: _iconSize,
onPressed: () {
widget.dismiss();
searchService.findAndHighlight(
queriedPattern,
unhighlight: true,
);
queriedPattern = '';
},
icon: const Icon(Icons.close),
tooltip: widget.localizations?.close ??
AppFlowyEditorLocalizations.current.closeFind,
),
],
), ),
replaceFlag showReplaceMenu
? Row( ? Padding(
children: [ padding: const EdgeInsets.only(
SizedBox( bottom: 8.0,
width: 200, ),
height: 30, child: ReplaceMenu(
child: TextField( editorState: widget.editorState,
key: const Key('replaceTextField'), searchService: searchService,
focusNode: replaceFocusNode, localizations: widget.localizations,
autofocus: false, ),
controller: replaceController,
onSubmitted: (_) {
_replaceSelectedWord();
Future.delayed(const Duration(milliseconds: 50)).then(
(value) => FocusScope.of(context)
.requestFocus(replaceFocusNode),
);
},
decoration: _buildInputDecoration(
widget.localizations?.replace ??
AppFlowyEditorLocalizations.current.replace,
),
),
),
FindMenuIconButton(
buttonKey: const Key('replaceSelectedButton'),
onPressed: () => _replaceSelectedWord(),
icon: const Icon(Icons.find_replace),
iconSize: _iconSize,
tooltip: widget.localizations?.replace ??
AppFlowyEditorLocalizations.current.replace,
),
FindMenuIconButton(
buttonKey: const Key('replaceAllButton'),
onPressed: () => _replaceAllMatches(),
icon: const Icon(Icons.change_circle_outlined),
iconSize: _iconSize,
tooltip: widget.localizations?.replaceAll ??
AppFlowyEditorLocalizations.current.replaceAll,
),
],
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
], ],
); );
} }
void _searchPattern() {
searchService.findAndHighlight(findController.text);
setState(() => queriedPattern = findController.text);
}
void _replaceSelectedWord() {
if (findController.text != queriedPattern) {
_searchPattern();
}
searchService.replaceSelectedWord(replaceController.text);
}
void _replaceAllMatches() {
if (findController.text != queriedPattern) {
_searchPattern();
}
searchService.replaceAllMatches(replaceController.text);
}
InputDecoration _buildInputDecoration(String hintText) {
return InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
border: const OutlineInputBorder(),
hintText: hintText,
);
}
} }
class FindMenuIconButton extends StatelessWidget { class FindMenu extends StatefulWidget {
const FindMenuIconButton({ const FindMenu({
super.key, super.key,
required this.icon, this.localizations,
this.onPressed, this.defaultFindText = '',
this.iconSize, this.caseSensitive = false,
this.tooltip, this.showReplaceMenu = false,
this.buttonKey, required this.onDismiss,
required this.editorState,
required this.style,
required this.searchService,
required this.onShowReplace,
}); });
final Widget icon; final EditorState editorState;
final Key? buttonKey; final VoidCallback onDismiss;
final VoidCallback? onPressed;
final double? iconSize; /// The style of the find and replace menu
final String? tooltip; ///
/// only works for SearchService, not for SearchServiceV2
final FindReplaceStyle style;
/// The localizations of the find and replace menu
final FindReplaceLocalizations? localizations;
/// The default text to search for
final String defaultFindText;
/// Whether the search should be case sensitive or not
final bool caseSensitive;
/// Whether to show the replace menu or not
final bool showReplaceMenu;
final void Function(bool showReplaceMenu) onShowReplace;
final SearchServiceV2 searchService;
@override
State<FindMenu> createState() => _FindMenuState();
}
class _FindMenuState extends State<FindMenu> {
final findTextFieldFocusNode = FocusNode();
final findTextEditingController = TextEditingController();
String queriedPattern = '';
bool showReplaceMenu = false;
bool caseSensitive = false;
@override
void initState() {
super.initState();
showReplaceMenu = widget.showReplaceMenu;
caseSensitive = widget.caseSensitive;
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);
findTextEditingController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( // the selectedIndex from searchService is 0-based
width: _iconButtonSize, final selectedIndex = widget.searchService.selectedIndex + 1;
height: _iconButtonSize, final matches = widget.searchService.matchedPositions.value;
child: IconButton( return Row(
key: buttonKey, children: [
onPressed: onPressed, // expand/collapse button
icon: icon, FindAndReplaceMenuIconButton(
iconSize: iconSize, icon: Icon(
tooltip: tooltip, showReplaceMenu ? Icons.expand_less : Icons.expand_more,
), ),
onPressed: () {
widget.onShowReplace(!showReplaceMenu);
setState(() {
showReplaceMenu = !showReplaceMenu;
});
},
),
// find text field
SizedBox(
width: 200,
height: 30,
child: TextField(
key: const Key('findTextField'),
focusNode: findTextFieldFocusNode,
controller: findTextEditingController,
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), () {
FocusScope.of(context).requestFocus(
findTextFieldFocusNode,
);
});
},
decoration: _buildInputDecoration(
widget.localizations?.find ??
AppFlowyEditorLocalizations.current.find,
),
),
),
// the count of matches
Container(
constraints: const BoxConstraints(minWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 8.0),
alignment: Alignment.centerLeft,
child: Text(
matches.isEmpty
? widget.localizations?.noResult ?? 'No Result'
: '$selectedIndex of ${matches.length}',
),
),
// case sensitive button
DecoratedBox(
decoration: BoxDecoration(
color: caseSensitive ? Colors.blue.shade400 : Colors.transparent,
borderRadius: BorderRadius.circular(0.0),
),
child: FindAndReplaceMenuIconButton(
onPressed: () => setState(() {
caseSensitive = !caseSensitive;
widget.searchService.caseSensitive = caseSensitive;
}),
icon: const Icon(Icons.keyboard_capslock),
tooltip: 'Aa',
),
),
// previous match button
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('previousMatchButton'),
onPressed: () => widget.searchService.navigateToMatch(moveUp: true),
icon: const Icon(Icons.arrow_upward),
tooltip: widget.localizations?.previousMatch ??
AppFlowyEditorLocalizations.current.previousMatch,
),
// next match button
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('nextMatchButton'),
onPressed: () => widget.searchService.navigateToMatch(),
icon: const Icon(Icons.arrow_downward),
tooltip: widget.localizations?.nextMatch ??
AppFlowyEditorLocalizations.current.nextMatch,
),
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('closeButton'),
onPressed: widget.onDismiss,
icon: const Icon(Icons.close),
tooltip: widget.localizations?.close ??
AppFlowyEditorLocalizations.current.closeFind,
),
],
); );
} }
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> {
final replaceTextFieldFocusNode = FocusNode();
final replaceTextEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: [
// placeholder for aligning the replace menu
const SizedBox(
width: _iconButtonSize,
),
SizedBox(
width: 200,
height: 30,
child: TextField(
key: const Key('replaceTextField'),
focusNode: replaceTextFieldFocusNode,
autofocus: false,
controller: replaceTextEditingController,
onSubmitted: (_) {
_replaceSelectedWord();
Future.delayed(const Duration(milliseconds: 50), () {
FocusScope.of(context).requestFocus(
replaceTextFieldFocusNode,
);
});
},
decoration: _buildInputDecoration(
widget.localizations?.replace ??
AppFlowyEditorLocalizations.current.replace,
),
),
),
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('replaceSelectedButton'),
onPressed: _replaceSelectedWord,
icon: const Icon(Icons.find_replace),
tooltip: widget.localizations?.replace ??
AppFlowyEditorLocalizations.current.replace,
),
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('replaceAllButton'),
onPressed: () => widget.searchService.replaceAllMatches(
replaceTextEditingController.text,
),
icon: const Icon(Icons.change_circle_outlined),
tooltip: widget.localizations?.replaceAll ??
AppFlowyEditorLocalizations.current.replaceAll,
),
],
);
}
void _replaceSelectedWord() {
widget.searchService.replaceSelectedWord(replaceTextEditingController.text);
}
}
InputDecoration _buildInputDecoration(String hintText) {
return InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
border: const OutlineInputBorder(),
hintText: hintText,
);
} }

View file

@ -34,11 +34,11 @@ class SearchService {
/// Finds the pattern in editorState.document and stores it in matchedPositions. /// Finds the pattern in editorState.document and stores it in matchedPositions.
/// Calls the highlightMatch method to highlight the pattern /// Calls the highlightMatch method to highlight the pattern
/// if it is found. /// if it is found.
void findAndHighlight(String pattern, {bool unhighlight = false}) { void findAndHighlight(String pattern, {bool unHighlight = false}) {
if (queriedPattern != pattern) { if (queriedPattern != pattern) {
//this means we have a new pattern, but before we highlight the new matches, //this means we have a new pattern, but before we highlight the new matches,
//lets unhiglight the old pattern //lets unhiglight the old pattern
findAndHighlight(queriedPattern, unhighlight: true); findAndHighlight(queriedPattern, unHighlight: true);
matchedPositions.clear(); matchedPositions.clear();
queriedPattern = pattern; queriedPattern = pattern;
} }
@ -58,7 +58,7 @@ class SearchService {
} }
} }
//finally we will highlight all the mathces. //finally we will highlight all the mathces.
_highlightAllMatches(pattern.length, unhighlight: unhighlight); _highlightAllMatches(pattern.length, unHighlight: unHighlight);
selectedIndex = -1; selectedIndex = -1;
} }
@ -90,7 +90,7 @@ class SearchService {
void _highlightAllMatches( void _highlightAllMatches(
int patternLength, { int patternLength, {
bool unhighlight = false, bool unHighlight = false,
}) { }) {
for (final match in matchedPositions) { for (final match in matchedPositions) {
final start = Position(path: match.path, offset: match.offset); final start = Position(path: match.path, offset: match.offset);
@ -100,16 +100,9 @@ class SearchService {
); );
final selection = Selection(start: start, end: end); final selection = Selection(start: start, end: end);
if (!unHighlight) {
if (unhighlight) { editorState.selection = selection;
editorState.formatDelta(
selection,
{AppFlowyRichTextKeys.findBackgroundColor: null},
);
} else {
_applySelectedHighlightColor(selection);
} }
editorState.undoManager.forgetRecentUndo();
} }
} }
@ -124,13 +117,6 @@ class SearchService {
final selection = Selection(start: start, end: end); final selection = Selection(start: start, end: end);
_applySelectedHighlightColor(selection, isSelected: true); _applySelectedHighlightColor(selection, isSelected: true);
await editorState.updateSelectionWithReason(
selection,
reason: isNavigating
? SelectionUpdateReason.searchNavigate
: SelectionUpdateReason.searchHighlight,
);
} }
/// This method takes in a boolean parameter moveUp, if set to true, /// This method takes in a boolean parameter moveUp, if set to true,
@ -180,7 +166,7 @@ class SearchService {
final position = matchedPositions[selectedIndex]; final position = matchedPositions[selectedIndex];
_selectWordAtPosition(position); _selectWordAtPosition(position);
//unhighlight the selected word before it is replaced //unHighlight the selected word before it is replaced
final selection = editorState.selection!; final selection = editorState.selection!;
editorState.formatDelta( editorState.formatDelta(
selection, selection,
@ -211,7 +197,7 @@ class SearchService {
return; return;
} }
_highlightAllMatches(queriedPattern.length, unhighlight: true); _highlightAllMatches(queriedPattern.length, unHighlight: true);
for (final match in matchedPositions.reversed.toList()) { for (final match in matchedPositions.reversed.toList()) {
final node = editorState.getNodeAtPath(match.path)!; final node = editorState.getNodeAtPath(match.path)!;

View file

@ -0,0 +1,180 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/search_algorithm.dart';
import 'package:flutter/foundation.dart';
const selectionExtraInfoDisableToolbar = 'selectionExtraInfoDisableToolbar';
class SearchServiceV2 {
SearchServiceV2({
required this.editorState,
});
final EditorState editorState;
//matchedPositions.value will contain a list of positions of the matched patterns
//the position here consists of the node and the starting offset of the
//matched pattern. We will use this to traverse between the matched patterns.
ValueNotifier<List<Position>> matchedPositions = ValueNotifier([]);
SearchAlgorithm searchAlgorithm = BoyerMoore();
String queriedPattern = '';
bool _caseSensitive = false;
bool get caseSensitive => _caseSensitive;
set caseSensitive(bool value) {
_caseSensitive = value;
findAndHighlight(queriedPattern);
}
int _selectedIndex = 0;
int get selectedIndex => _selectedIndex;
set selectedIndex(int index) {
_selectedIndex = matchedPositions.value.isEmpty
? -1
: index.clamp(0, matchedPositions.value.length - 1);
currentSelectedIndex.value = _selectedIndex;
}
ValueNotifier<int> currentSelectedIndex = ValueNotifier(0);
void dispose() {
matchedPositions.dispose();
currentSelectedIndex.dispose();
}
void findAndHighlight(
String pattern, {
bool unHighlight = false,
}) {
if (queriedPattern != pattern) {
matchedPositions.value.clear();
queriedPattern = pattern;
}
if (pattern.isEmpty) return;
matchedPositions.value = _getMatchedPositions(
pattern: pattern,
nodes: editorState.document.root.children,
);
if (matchedPositions.value.isNotEmpty) {
selectedIndex = selectedIndex;
_highlightCurrentMatch(
pattern,
);
} else {
editorState.updateSelectionWithReason(null);
}
}
List<Position> _getMatchedPositions({
required String pattern,
Iterable<Node> nodes = const [],
}) {
final List<Position> result = [];
for (final node in nodes) {
if (node.delta != null) {
final text = node.delta!.toPlainText();
List<int> matches = searchAlgorithm.searchMethod(
caseSensitive ? pattern : pattern.toLowerCase(),
caseSensitive ? text : text.toLowerCase(),
);
// we will store this list of offsets along with their path,
// in a list of positions.
for (int matchedOffset in matches) {
result.add(
Position(path: node.path, offset: matchedOffset),
);
}
}
result.addAll(
_getMatchedPositions(pattern: pattern, nodes: node.children),
);
}
return result;
}
void _highlightCurrentMatch(
String pattern,
) {
final start = matchedPositions.value[selectedIndex];
final end = Position(
path: start.path,
offset: start.offset + pattern.length,
);
editorState.updateSelectionWithReason(
Selection(start: start, end: end),
extraInfo: {
selectionExtraInfoDisableToolbar: true,
},
);
}
/// This method takes in a boolean parameter moveUp, if set to true,
/// the match located above the current selected match is newly selected.
/// Otherwise the match below the current selected match is newly selected.
void navigateToMatch({bool moveUp = false}) {
if (matchedPositions.value.isEmpty) return;
if (moveUp) {
selectedIndex = selectedIndex <= 0
? matchedPositions.value.length - 1
: selectedIndex - 1;
} else {
selectedIndex = selectedIndex >= matchedPositions.value.length - 1
? 0
: selectedIndex + 1;
}
_highlightCurrentMatch(queriedPattern);
}
/// Replaces the current selected word with replaceText.
/// After replacing the selected word, this method selects the next
/// matched word if that exists.
Future<void> replaceSelectedWord(String replaceText) async {
if (replaceText.isEmpty ||
queriedPattern.isEmpty ||
matchedPositions.value.isEmpty) {
return;
}
final start = matchedPositions.value[selectedIndex];
final node = editorState.getNodeAtPath(start.path)!;
final transaction = editorState.transaction
..replaceText(
node,
start.offset,
queriedPattern.length,
replaceText,
);
await editorState.apply(transaction);
matchedPositions.value.clear();
findAndHighlight(queriedPattern);
}
/// Replaces all the found occurances of pattern with replaceText
void replaceAllMatches(String replaceText) {
if (replaceText.isEmpty ||
queriedPattern.isEmpty ||
matchedPositions.value.isEmpty) {
return;
}
// _highlightAllMatches(queriedPattern.length, unHighlight: true);
for (final match in matchedPositions.value.reversed.toList()) {
final node = editorState.getNodeAtPath(match.path)!;
final transaction = editorState.transaction
..replaceText(
node,
match.offset,
queriedPattern.length,
replaceText,
);
editorState.apply(transaction);
}
matchedPositions.value.clear();
}
}

View file

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/search_service_v2.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class FloatingToolbarStyle { class FloatingToolbarStyle {
@ -102,9 +103,12 @@ class _FloatingToolbarState extends State<FloatingToolbar>
void _onSelectionChanged() { void _onSelectionChanged() {
final selection = editorState.selection; final selection = editorState.selection;
final selectionType = editorState.selectionType; final selectionType = editorState.selectionType;
if (selection == null || if (selection == null ||
selection.isCollapsed || selection.isCollapsed ||
selectionType == SelectionType.block) { selectionType == SelectionType.block ||
editorState.selectionExtraInfo?[selectionExtraInfoDisableToolbar] ==
true) {
_clear(); _clear();
} else { } else {
// uses debounce to avoid the computing the rects too frequently. // uses debounce to avoid the computing the rects too frequently.
@ -144,6 +148,10 @@ class _FloatingToolbarState extends State<FloatingToolbar>
if (editorState.selection?.isCollapsed ?? true) { if (editorState.selection?.isCollapsed ?? true) {
return; return;
} }
if (editorState.selectionExtraInfo?[selectionExtraInfoDisableToolbar] ==
true) {
return;
}
final rects = editorState.selectionRects(); final rects = editorState.selectionRects();
if (rects.isEmpty) { if (rects.isEmpty) {
return; return;

View file

@ -29,7 +29,6 @@ enum SelectionUpdateReason {
transaction, // like insert, delete, format transaction, // like insert, delete, format
selectAll, selectAll,
searchHighlight, // Highlighting search results searchHighlight, // Highlighting search results
searchNavigate, // Navigate to a search result
} }
enum SelectionType { enum SelectionType {
@ -109,6 +108,8 @@ class EditorState {
SelectionUpdateReason _selectionUpdateReason = SelectionUpdateReason.uiEvent; SelectionUpdateReason _selectionUpdateReason = SelectionUpdateReason.uiEvent;
SelectionUpdateReason get selectionUpdateReason => _selectionUpdateReason; SelectionUpdateReason get selectionUpdateReason => _selectionUpdateReason;
Map? selectionExtraInfo;
// Service reference. // Service reference.
final service = EditorService(); final service = EditorService();
@ -175,6 +176,7 @@ class EditorState {
Future<void> updateSelectionWithReason( Future<void> updateSelectionWithReason(
Selection? selection, { Selection? selection, {
SelectionUpdateReason reason = SelectionUpdateReason.transaction, SelectionUpdateReason reason = SelectionUpdateReason.transaction,
Map? extraInfo,
}) async { }) async {
final completer = Completer<void>(); final completer = Completer<void>();
@ -185,6 +187,7 @@ class EditorState {
} }
// broadcast to other users here // broadcast to other users here
selectionExtraInfo = extraInfo;
_selectionUpdateReason = reason; _selectionUpdateReason = reason;
this.selection = selection; this.selection = selection;
@ -213,6 +216,16 @@ class EditorState {
Timer? _debouncedSealHistoryItemTimer; Timer? _debouncedSealHistoryItemTimer;
final bool _enableCheckIntegrity = false; final bool _enableCheckIntegrity = false;
// the value of the notifier is meaningless, just for triggering the callbacks.
final ValueNotifier<int> onDispose = ValueNotifier(0);
void dispose() {
_observer.close();
_debouncedSealHistoryItemTimer?.cancel();
onDispose.value += 1;
onDispose.dispose();
}
/// Apply the transaction to the state. /// Apply the transaction to the state.
/// ///
/// The options can be used to determine whether the editor /// The options can be used to determine whether the editor

View file

@ -32,7 +32,7 @@ void main() async {
await tester.tap(find.byKey(const Key('closeButton'))); await tester.tap(find.byKey(const Key('closeButton')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsNothing); expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
expect(find.byType(TextField), findsNothing); expect(find.byType(TextField), findsNothing);
expect(find.byType(IconButton), findsNothing); expect(find.byType(IconButton), findsNothing);
@ -68,19 +68,22 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);
// Checking if current selection consists an occurance of matched pattern. // Checking if current selection consists an occurance of matched pattern.
final selection = editor.editorState.selection;
// We expect the first occurance of the pattern to be found and selected, // We expect the first occurance of the pattern to be found and selected,
// this is because we send a testTextInput.receiveAction(TextInputAction.done) // this is because we send a testTextInput.receiveAction(TextInputAction.done)
// event during submitting our text input, thus the second match is selected. // event during submitting our text input, thus the second match is selected.
expect(selection != null, true); expect(
expect(selection!.start, Position(path: [0], offset: 0)); editor.editorState.selection,
expect(selection.end, Position(path: [0], offset: pattern.length)); Selection.single(
path: [0],
startOffset: 0,
endOffset: pattern.length,
),
);
await editor.dispose(); await editor.dispose();
}); });
@ -101,7 +104,7 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);
@ -164,11 +167,6 @@ void main() async {
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);
// This will call naviateToMatch and select the first match
await editor.pressKey(
key: LogicalKeyboardKey.enter,
);
// We expect the first occurance of the pattern to be found and selected // We expect the first occurance of the pattern to be found and selected
checkCurrentSelection(editor, [0], 0, pattern.length); checkCurrentSelection(editor, [0], 0, pattern.length);
@ -190,7 +188,7 @@ void main() async {
await editor.dispose(); await editor.dispose();
}); });
testWidgets('found matches are unhighlighted when findMenu closed', testWidgets('found matches are unHighlighted when findMenu closed',
(tester) async { (tester) async {
const pattern = 'Welcome'; const pattern = 'Welcome';
const closeBtnKey = Key('closeButton'); const closeBtnKey = Key('closeButton');
@ -205,7 +203,7 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);
@ -227,7 +225,7 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Closes the findMenuWidget // Closes the findMenuWidget
expect(find.byType(FindMenuWidget), findsNothing); expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
// We expect that the current selected node is NOT highlighted. // We expect that the current selected node is NOT highlighted.
checkIfNotHighlighted(node, selection, expectedResult: true); checkIfNotHighlighted(node, selection, expectedResult: true);
@ -235,7 +233,7 @@ void main() async {
await editor.dispose(); await editor.dispose();
}); });
testWidgets('old matches are unhighlighted when new pattern is searched', testWidgets('old matches are unHighlighted when new pattern is searched',
(tester) async { (tester) async {
const textLine1 = 'Welcome to Appflowy 😁'; const textLine1 = 'Welcome to Appflowy 😁';
const textLine2 = 'Appflowy is made with Flutter, Rust and ❤️'; const textLine2 = 'Appflowy is made with Flutter, Rust and ❤️';
@ -252,7 +250,7 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);
@ -307,7 +305,7 @@ Future<void> _prepareFindAndInputPattern(
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);

View file

@ -1,10 +1,9 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_widget.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../infra/testable_editor.dart'; import '../infra/testable_editor.dart';
import 'find_replace_menu_utils.dart'; import 'find_replace_menu_utils.dart';
@ -31,7 +30,7 @@ void main() async {
await tester.tap(find.byKey(const Key('closeButton'))); await tester.tap(find.byKey(const Key('closeButton')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsNothing); expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
expect(find.byType(TextField), findsNothing); expect(find.byType(TextField), findsNothing);
expect(find.byType(IconButton), findsNothing); expect(find.byType(IconButton), findsNothing);
}); });
@ -49,7 +48,7 @@ void main() async {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern, isReplaceField: true); await enterInputIntoFindDialog(tester, pattern, isReplaceField: true);
@ -77,7 +76,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true); await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter //we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, pattern); await enterInputIntoFindDialog(tester, pattern);
@ -116,14 +115,10 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true); await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter //we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound); await enterInputIntoFindDialog(tester, patternToBeFound);
await editor.pressKey(
key: LogicalKeyboardKey.enter,
);
await tester.pumpAndSettle();
//we expect the found pattern to be highlighted //we expect the found pattern to be highlighted
final node = editor.nodeAtPath([0]); final node = editor.nodeAtPath([0]);
@ -148,7 +143,7 @@ void main() async {
await editor.dispose(); await editor.dispose();
}); });
testWidgets('''within multiple matched patterns replace testWidgets('''within multiple matched patterns replace
should only replace the currently selected match''', (tester) async { should only replace the currently selected match''', (tester) async {
const patternToBeFound = 'Welcome'; const patternToBeFound = 'Welcome';
const replacePattern = 'Salute'; const replacePattern = 'Salute';
@ -163,7 +158,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true); await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
// we put the pattern in the find dialog and press enter // we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound); await enterInputIntoFindDialog(tester, patternToBeFound);
@ -213,7 +208,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true); await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter //we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound); await enterInputIntoFindDialog(tester, patternToBeFound);
@ -251,7 +246,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true); await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter //we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound); await enterInputIntoFindDialog(tester, patternToBeFound);
@ -292,7 +287,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true); await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter //we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound); await enterInputIntoFindDialog(tester, patternToBeFound);

View file

@ -35,6 +35,7 @@ class TestableFindAndReplaceCommands {
close: 'Close', close: 'Close',
replace: 'Replace', replace: 'Replace',
replaceAll: 'Replace all', replaceAll: 'Replace all',
noResult: 'No result',
), ),
style: FindReplaceStyle( style: FindReplaceStyle(
selectedHighlightColor: selectedHighlightColor, selectedHighlightColor: selectedHighlightColor,
@ -57,7 +58,7 @@ Future<void> prepareFindAndReplaceDialog(
await tester.pumpAndSettle(const Duration(milliseconds: 1000)); await tester.pumpAndSettle(const Duration(milliseconds: 1000));
expect(find.byType(FindMenuWidget), findsOneWidget); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
} }
Future<void> enterInputIntoFindDialog( Future<void> enterInputIntoFindDialog(
@ -71,8 +72,10 @@ Future<void> enterInputIntoFindDialog(
await tester.tap(find.byKey(textInputKey)); await tester.tap(find.byKey(textInputKey));
await tester.enterText(find.byKey(textInputKey), pattern); await tester.enterText(find.byKey(textInputKey), pattern);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.testTextInput.receiveAction(TextInputAction.done); if (isReplaceField) {
await tester.pumpAndSettle(); await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
}
} }
Future<void> enterInputIntoReplaceDialog( Future<void> enterInputIntoReplaceDialog(
@ -101,18 +104,18 @@ void checkIfNotHighlighted(
Selection selection, { Selection selection, {
bool expectedResult = true, bool expectedResult = true,
}) { }) {
//if the expectedResult is true: // if the expectedResult is true:
//we expect that nothing is highlighted in our current document. // we expect that nothing is highlighted in our current document.
//otherwise: we expect that something is highlighted. // otherwise: we expect that something is highlighted.
expect( // expect(
node.allSatisfyInSelection(selection, (delta) { // node.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every( // return delta.whereType<TextInsert>().every(
(e) => // (e) =>
e.attributes?[AppFlowyRichTextKeys.findBackgroundColor] == null, // e.attributes?[AppFlowyRichTextKeys.findBackgroundColor] == null,
); // );
}), // }),
expectedResult, // expectedResult,
); // );
} }
void checkIfHighlightedWithProperColors( void checkIfHighlightedWithProperColors(
@ -120,16 +123,16 @@ void checkIfHighlightedWithProperColors(
Selection selection, Selection selection,
String expectedColor, String expectedColor,
) { ) {
expect( // expect(
node.allSatisfyInSelection(selection, (delta) { // node.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every( // return delta.whereType<TextInsert>().every(
(e) => // (e) =>
e.attributes?[AppFlowyRichTextKeys.findBackgroundColor] == // e.attributes?[AppFlowyRichTextKeys.findBackgroundColor] ==
expectedColor, // expectedColor,
); // );
}), // }),
true, // true,
); // );
} }
void checkCurrentSelection( void checkCurrentSelection(