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

View file

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

View file

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

View file

@ -36,6 +36,8 @@ class FindReplaceMenu implements FindReplaceService {
editorState.service.scrollService?.enable();
}
editorState.onDispose.removeListener(dismiss);
_findReplaceMenuEntry?.remove();
_findReplaceMenuEntry = null;
@ -59,6 +61,8 @@ class FindReplaceMenu implements FindReplaceService {
return;
}
editorState.onDispose.addListener(dismiss);
_findReplaceMenuEntry = OverlayEntry(
builder: (context) {
return Positioned(
@ -78,10 +82,10 @@ class FindReplaceMenu implements FindReplaceService {
],
borderRadius: BorderRadius.circular(6.0),
),
child: FindMenuWidget(
dismiss: dismiss,
child: FindAndReplaceMenuWidget(
onDismiss: dismiss,
editorState: editorState,
replaceFlag: replaceFlag,
showReplaceMenu: replaceFlag,
localizations: localizations,
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,93 +1,218 @@
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';
const double _iconSize = 20;
const double _iconButtonSize = 40;
const double _iconButtonSize = 30;
class FindMenuWidget extends StatefulWidget {
const FindMenuWidget({
class FindAndReplaceMenuWidget extends StatefulWidget {
const FindAndReplaceMenuWidget({
super.key,
required this.dismiss,
required this.editorState,
required this.replaceFlag,
this.localizations,
this.defaultFindText = '',
this.caseSensitive = false,
required this.onDismiss,
required this.editorState,
required this.showReplaceMenu,
required this.style,
});
final VoidCallback dismiss;
final EditorState editorState;
final bool replaceFlag;
final FindReplaceLocalizations? localizations;
final VoidCallback onDismiss;
/// 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;
/// 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
State<FindMenuWidget> createState() => _FindMenuWidgetState();
State<FindAndReplaceMenuWidget> createState() =>
_FindAndReplaceMenuWidgetState();
}
class _FindMenuWidgetState extends State<FindMenuWidget> {
final focusNode = FocusNode();
final replaceFocusNode = FocusNode();
final findController = TextEditingController();
final replaceController = TextEditingController();
String queriedPattern = '';
bool replaceFlag = false;
late SearchService searchService;
class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
bool showReplaceMenu = false;
late SearchServiceV2 searchService = SearchServiceV2(
editorState: widget.editorState,
);
@override
void initState() {
super.initState();
replaceFlag = widget.replaceFlag;
searchService = SearchService(
editorState: widget.editorState,
style: SearchStyle(
selectedHighlightColor: widget.style.selectedHighlightColor,
unselectedHighlightColor: widget.style.unselectedHighlightColor,
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
findController.addListener(_searchPattern);
}
@override
void dispose() {
findController.removeListener(_searchPattern);
focusNode.dispose();
super.dispose();
showReplaceMenu = widget.showReplaceMenu;
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: FindMenu(
onDismiss: widget.onDismiss,
editorState: widget.editorState,
style: widget.style,
searchService: searchService,
defaultFindText: widget.defaultFindText,
caseSensitive: widget.caseSensitive,
localizations: widget.localizations,
showReplaceMenu: showReplaceMenu,
onShowReplace: (value) => setState(() {
showReplaceMenu = value;
}),
),
),
showReplaceMenu
? Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
),
child: ReplaceMenu(
editorState: widget.editorState,
searchService: searchService,
localizations: widget.localizations,
),
)
: const SizedBox.shrink(),
],
);
}
}
class FindMenu extends StatefulWidget {
const FindMenu({
super.key,
this.localizations,
this.defaultFindText = '',
this.caseSensitive = false,
this.showReplaceMenu = false,
required this.onDismiss,
required this.editorState,
required this.style,
required this.searchService,
required this.onShowReplace,
});
final EditorState editorState;
final VoidCallback onDismiss;
/// The style of the find and replace menu
///
/// 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
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: [
IconButton(
onPressed: () => setState(
() => replaceFlag = !replaceFlag,
// expand/collapse button
FindAndReplaceMenuIconButton(
icon: Icon(
showReplaceMenu ? Icons.expand_less : Icons.expand_more,
),
icon: replaceFlag
? const Icon(Icons.expand_less)
: const Icon(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: focusNode,
controller: findController,
focusNode: findTextFieldFocusNode,
controller: findTextEditingController,
onSubmitted: (_) {
searchService.navigateToMatch();
widget.searchService.navigateToMatch();
// Workaround for editor forcing focus
Future.delayed(const Duration(milliseconds: 50)).then(
(value) => FocusScope.of(context).requestFocus(focusNode),
// 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 ??
@ -95,57 +220,119 @@ class _FindMenuWidgetState extends State<FindMenuWidget> {
),
),
),
FindMenuIconButton(
buttonKey: const Key('previousMatchButton'),
iconSize: _iconSize,
onPressed: () => searchService.navigateToMatch(moveUp: true),
// 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,
),
FindMenuIconButton(
buttonKey: const Key('nextMatchButton'),
iconSize: _iconSize,
onPressed: () => searchService.navigateToMatch(),
// 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,
),
FindMenuIconButton(
buttonKey: const Key('closeButton'),
iconSize: _iconSize,
onPressed: () {
widget.dismiss();
searchService.findAndHighlight(
queriedPattern,
unhighlight: true,
);
queriedPattern = '';
},
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('closeButton'),
onPressed: widget.onDismiss,
icon: const Icon(Icons.close),
tooltip: widget.localizations?.close ??
AppFlowyEditorLocalizations.current.closeFind,
),
],
),
replaceFlag
? Row(
);
}
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: replaceFocusNode,
focusNode: replaceTextFieldFocusNode,
autofocus: false,
controller: replaceController,
controller: replaceTextEditingController,
onSubmitted: (_) {
_replaceSelectedWord();
Future.delayed(const Duration(milliseconds: 50)).then(
(value) => FocusScope.of(context)
.requestFocus(replaceFocusNode),
Future.delayed(const Duration(milliseconds: 50), () {
FocusScope.of(context).requestFocus(
replaceTextFieldFocusNode,
);
});
},
decoration: _buildInputDecoration(
widget.localizations?.replace ??
@ -153,85 +340,35 @@ class _FindMenuWidgetState extends State<FindMenuWidget> {
),
),
),
FindMenuIconButton(
buttonKey: const Key('replaceSelectedButton'),
onPressed: () => _replaceSelectedWord(),
FindAndReplaceMenuIconButton(
iconButtonKey: 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(),
FindAndReplaceMenuIconButton(
iconButtonKey: const Key('replaceAllButton'),
onPressed: () => widget.searchService.replaceAllMatches(
replaceTextEditingController.text,
),
icon: const Icon(Icons.change_circle_outlined),
iconSize: _iconSize,
tooltip: widget.localizations?.replaceAll ??
AppFlowyEditorLocalizations.current.replaceAll,
),
],
)
: const SizedBox.shrink(),
],
);
}
void _searchPattern() {
searchService.findAndHighlight(findController.text);
setState(() => queriedPattern = findController.text);
}
void _replaceSelectedWord() {
if (findController.text != queriedPattern) {
_searchPattern();
}
searchService.replaceSelectedWord(replaceController.text);
widget.searchService.replaceSelectedWord(replaceTextEditingController.text);
}
}
void _replaceAllMatches() {
if (findController.text != queriedPattern) {
_searchPattern();
}
searchService.replaceAllMatches(replaceController.text);
}
InputDecoration _buildInputDecoration(String hintText) {
InputDecoration _buildInputDecoration(String hintText) {
return InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
border: const OutlineInputBorder(),
hintText: hintText,
);
}
}
class FindMenuIconButton extends StatelessWidget {
const FindMenuIconButton({
super.key,
required this.icon,
this.onPressed,
this.iconSize,
this.tooltip,
this.buttonKey,
});
final Widget icon;
final Key? buttonKey;
final VoidCallback? onPressed;
final double? iconSize;
final String? tooltip;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _iconButtonSize,
height: _iconButtonSize,
child: IconButton(
key: buttonKey,
onPressed: onPressed,
icon: icon,
iconSize: iconSize,
tooltip: tooltip,
),
);
}
}

View file

@ -34,11 +34,11 @@ class SearchService {
/// Finds the pattern in editorState.document and stores it in matchedPositions.
/// Calls the highlightMatch method to highlight the pattern
/// if it is found.
void findAndHighlight(String pattern, {bool unhighlight = false}) {
void findAndHighlight(String pattern, {bool unHighlight = false}) {
if (queriedPattern != pattern) {
//this means we have a new pattern, but before we highlight the new matches,
//lets unhiglight the old pattern
findAndHighlight(queriedPattern, unhighlight: true);
findAndHighlight(queriedPattern, unHighlight: true);
matchedPositions.clear();
queriedPattern = pattern;
}
@ -58,7 +58,7 @@ class SearchService {
}
}
//finally we will highlight all the mathces.
_highlightAllMatches(pattern.length, unhighlight: unhighlight);
_highlightAllMatches(pattern.length, unHighlight: unHighlight);
selectedIndex = -1;
}
@ -90,7 +90,7 @@ class SearchService {
void _highlightAllMatches(
int patternLength, {
bool unhighlight = false,
bool unHighlight = false,
}) {
for (final match in matchedPositions) {
final start = Position(path: match.path, offset: match.offset);
@ -100,16 +100,9 @@ class SearchService {
);
final selection = Selection(start: start, end: end);
if (unhighlight) {
editorState.formatDelta(
selection,
{AppFlowyRichTextKeys.findBackgroundColor: null},
);
} else {
_applySelectedHighlightColor(selection);
if (!unHighlight) {
editorState.selection = selection;
}
editorState.undoManager.forgetRecentUndo();
}
}
@ -124,13 +117,6 @@ class SearchService {
final selection = Selection(start: start, end: end);
_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,
@ -180,7 +166,7 @@ class SearchService {
final position = matchedPositions[selectedIndex];
_selectWordAtPosition(position);
//unhighlight the selected word before it is replaced
//unHighlight the selected word before it is replaced
final selection = editorState.selection!;
editorState.formatDelta(
selection,
@ -211,7 +197,7 @@ class SearchService {
return;
}
_highlightAllMatches(queriedPattern.length, unhighlight: true);
_highlightAllMatches(queriedPattern.length, unHighlight: true);
for (final match in matchedPositions.reversed.toList()) {
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 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/search_service_v2.dart';
import 'package:flutter/material.dart';
class FloatingToolbarStyle {
@ -102,9 +103,12 @@ class _FloatingToolbarState extends State<FloatingToolbar>
void _onSelectionChanged() {
final selection = editorState.selection;
final selectionType = editorState.selectionType;
if (selection == null ||
selection.isCollapsed ||
selectionType == SelectionType.block) {
selectionType == SelectionType.block ||
editorState.selectionExtraInfo?[selectionExtraInfoDisableToolbar] ==
true) {
_clear();
} else {
// uses debounce to avoid the computing the rects too frequently.
@ -144,6 +148,10 @@ class _FloatingToolbarState extends State<FloatingToolbar>
if (editorState.selection?.isCollapsed ?? true) {
return;
}
if (editorState.selectionExtraInfo?[selectionExtraInfoDisableToolbar] ==
true) {
return;
}
final rects = editorState.selectionRects();
if (rects.isEmpty) {
return;

View file

@ -29,7 +29,6 @@ enum SelectionUpdateReason {
transaction, // like insert, delete, format
selectAll,
searchHighlight, // Highlighting search results
searchNavigate, // Navigate to a search result
}
enum SelectionType {
@ -109,6 +108,8 @@ class EditorState {
SelectionUpdateReason _selectionUpdateReason = SelectionUpdateReason.uiEvent;
SelectionUpdateReason get selectionUpdateReason => _selectionUpdateReason;
Map? selectionExtraInfo;
// Service reference.
final service = EditorService();
@ -175,6 +176,7 @@ class EditorState {
Future<void> updateSelectionWithReason(
Selection? selection, {
SelectionUpdateReason reason = SelectionUpdateReason.transaction,
Map? extraInfo,
}) async {
final completer = Completer<void>();
@ -185,6 +187,7 @@ class EditorState {
}
// broadcast to other users here
selectionExtraInfo = extraInfo;
_selectionUpdateReason = reason;
this.selection = selection;
@ -213,6 +216,16 @@ class EditorState {
Timer? _debouncedSealHistoryItemTimer;
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.
///
/// 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.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsNothing);
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
expect(find.byType(TextField), findsNothing);
expect(find.byType(IconButton), findsNothing);
@ -68,19 +68,22 @@ void main() async {
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, 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,
// this is because we send a testTextInput.receiveAction(TextInputAction.done)
// event during submitting our text input, thus the second match is selected.
expect(selection != null, true);
expect(selection!.start, Position(path: [0], offset: 0));
expect(selection.end, Position(path: [0], offset: pattern.length));
expect(
editor.editorState.selection,
Selection.single(
path: [0],
startOffset: 0,
endOffset: pattern.length,
),
);
await editor.dispose();
});
@ -101,7 +104,7 @@ void main() async {
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern);
@ -164,11 +167,6 @@ void main() async {
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
checkCurrentSelection(editor, [0], 0, pattern.length);
@ -190,7 +188,7 @@ void main() async {
await editor.dispose();
});
testWidgets('found matches are unhighlighted when findMenu closed',
testWidgets('found matches are unHighlighted when findMenu closed',
(tester) async {
const pattern = 'Welcome';
const closeBtnKey = Key('closeButton');
@ -205,7 +203,7 @@ void main() async {
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern);
@ -227,7 +225,7 @@ void main() async {
await tester.pumpAndSettle();
// Closes the findMenuWidget
expect(find.byType(FindMenuWidget), findsNothing);
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
// We expect that the current selected node is NOT highlighted.
checkIfNotHighlighted(node, selection, expectedResult: true);
@ -235,7 +233,7 @@ void main() async {
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 {
const textLine1 = 'Welcome to Appflowy 😁';
const textLine2 = 'Appflowy is made with Flutter, Rust and ❤️';
@ -252,7 +250,7 @@ void main() async {
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern);
@ -307,7 +305,7 @@ Future<void> _prepareFindAndInputPattern(
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
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/services.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 'find_replace_menu_utils.dart';
@ -31,7 +30,7 @@ void main() async {
await tester.tap(find.byKey(const Key('closeButton')));
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsNothing);
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
expect(find.byType(TextField), findsNothing);
expect(find.byType(IconButton), findsNothing);
});
@ -49,7 +48,7 @@ void main() async {
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
await enterInputIntoFindDialog(tester, pattern, isReplaceField: true);
@ -77,7 +76,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, pattern);
@ -116,14 +115,10 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound);
await editor.pressKey(
key: LogicalKeyboardKey.enter,
);
await tester.pumpAndSettle();
//we expect the found pattern to be highlighted
final node = editor.nodeAtPath([0]);
@ -163,7 +158,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
// we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound);
@ -213,7 +208,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound);
@ -251,7 +246,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound);
@ -292,7 +287,7 @@ void main() async {
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
expect(find.byType(FindMenuWidget), findsOneWidget);
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
//we put the pattern in the find dialog and press enter
await enterInputIntoFindDialog(tester, patternToBeFound);

View file

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