feat: add regex and case sensitive to FindReplaceMenu (#480)

This commit is contained in:
Sun Jiao 2023-10-10 15:53:38 +08:00 • committed by GitHub
commit 8bfd779bfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 723 additions and 41 deletions

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="5.325356mm"
height="5.321744mm"
viewBox="0 0 5.3253561 5.321744"
version="1.1"
id="svg1"
sodipodi:docname="case_sensitive.svg"
inkscape:export-filename="case_sensitive.png"
inkscape:export-xdpi="95.280991"
inkscape:export-ydpi="95.280991"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="18.712277"
inkscape:cx="10.207202"
inkscape:cy="4.9432787"
inkscape:window-width="1440"
inkscape:window-height="831"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="图层 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-39.417839,-61.454391)">
<path
d="m 41.377391,63.050708 q -0.452966,0 -0.706966,0.313266 -0.254,0.309033 -0.254,0.8509 0,0.537633 0.237067,0.846666 0.237066,0.309033 0.719666,0.309033 0.194733,0 0.372533,-0.03387 0.182033,-0.0381 0.368299,-0.09737 v 0.3937 q -0.177799,0.06773 -0.364066,0.09737 -0.182033,0.03387 -0.4318,0.03387 -0.465666,0 -0.770466,-0.190499 -0.304799,-0.194734 -0.457199,-0.541867 -0.1524,-0.351366 -0.1524,-0.821266 0,-0.457199 0.1651,-0.808566 0.169333,-0.351366 0.491066,-0.546099 0.321733,-0.198967 0.787399,-0.198967 0.2286,0 0.4445,0.0508 0.220133,0.04657 0.402166,0.135467 l -0.169333,0.381 q -0.148166,-0.06773 -0.321733,-0.118534 -0.169333,-0.05503 -0.359833,-0.05503 z m 2.256362,2.713564 q -0.474132,0 -0.761999,-0.279399 -0.287866,-0.283633 -0.287866,-0.889 0,-0.423333 0.1397,-0.685799 0.139699,-0.262467 0.385233,-0.385233 0.245533,-0.127 0.558799,-0.127 0.1905,0 0.3556,0.04233 0.169333,0.0381 0.2794,0.0889 l -0.135467,0.364067 q -0.118533,-0.04657 -0.254,-0.08043 -0.131233,-0.03387 -0.249766,-0.03387 -0.618066,0 -0.618066,0.812799 0,0.389466 0.152399,0.592666 0.1524,0.2032 0.448733,0.2032 0.1905,0 0.334433,-0.04233 0.148167,-0.04233 0.270934,-0.110067 v 0.389467 q -0.122767,0.07197 -0.2667,0.105833 -0.143933,0.03387 -0.351367,0.03387 z"
id="text1"
style="font-weight:500;font-size:4.23333px;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Medium';stroke-width:1.32292;stroke-linecap:round"
aria-label="Cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

61
assets/images/regex.svg Normal file
View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="5.325356mm"
height="5.321744mm"
viewBox="0 0 5.3253561 5.321744"
version="1.1"
id="svg1"
sodipodi:docname="regex.svg"
inkscape:export-filename="case_sensitive.png"
inkscape:export-xdpi="95.280991"
inkscape:export-ydpi="95.280991"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="18.712277"
inkscape:cx="10.207202"
inkscape:cy="6.4930634"
inkscape:window-width="1440"
inkscape:window-height="831"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="text1"
showgrid="false" />
<defs
id="defs1" />
<g
inkscape:label="图层 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-39.417839,-61.454391)">
<g
id="text1"
style="font-weight:500;font-size:4.23333px;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Medium';stroke-width:1.32292;stroke-linecap:round"
aria-label="R*">
<path
d="m 40.871683,62.61275 q 0.617793,0 0.910907,0.229981 0.297622,0.229981 0.297622,0.698963 0,0.248019 -0.0947,0.419377 -0.09019,0.16685 -0.234492,0.275076 -0.144301,0.103721 -0.302132,0.16685 l 0.901888,1.429491 h -0.559169 l -0.775623,-1.303227 h -0.55917 v 1.303227 h -0.48702 V 62.61275 Z m -0.03156,0.414868 h -0.383302 v 1.095793 h 0.405849 q 0.374284,0 0.545642,-0.139792 0.171359,-0.144303 0.171359,-0.423888 0,-0.288604 -0.180377,-0.410358 -0.180379,-0.121755 -0.559171,-0.121755 z"
id="path2"
style="stroke-width:1.4092" />
<path
style="font-weight:bold;font-family:'Droid Sans Fallback';-inkscape-font-specification:'Droid Sans Fallback';stroke-width:1.23353"
d="m 44.213106,62.806718 -0.636032,0.333435 0.636032,0.335363 -0.146479,0.27176 -0.641815,-0.354636 v 0.663015 H 43.097158 V 63.39264 l -0.643742,0.354636 -0.146481,-0.27176 0.643743,-0.335363 -0.643743,-0.333435 0.146481,-0.27176 0.643742,0.350781 v -0.663015 h 0.327654 v 0.663015 l 0.641815,-0.350781 z"
id="path3"
sodipodi:nodetypes="ccccccccccccccccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -126,6 +126,8 @@
"closeFind": "Close",
"replace": "Replace",
"replaceAll": "Replace all",
"regex": "Regex",
"caseSensitive": "Case sensitive",
"uploadImage": "Upload Image",
"urlImage": "URL Image",
"incorrectLink": "Incorrect Link",

View file

@ -126,6 +126,8 @@
"closeFind": "关闭",
"replace": "替换",
"replaceAll": "替换全部",
"regex": "正则表达式",
"caseSensitive": "区分大小写",
"uploadImage": "上传图片",
"urlImage": "网络图片",
"incorrectLink": "链接错误",

View file

@ -126,6 +126,8 @@
"closeFind": "關閉",
"replace": "取代",
"replaceAll": "取代全部",
"regex": "正規表示式",
"caseSensitive": "區分大小寫",
"uploadImage": "上載圖片",
"urlImage": "網路圖片",
"incorrectLink": "連結錯誤",

View file

@ -122,6 +122,8 @@ KeyEventResult _showFindAndReplaceDialog(
showReplaceMenu: openReplace,
localizations: localizations,
style: style,
showRegexButton: true,
showCaseSensitiveButton: true,
);
_findReplaceService?.show();

View file

@ -1 +1 @@
export 'search_service_v2.dart';
export 'search_service_v3.dart' hide MatchWrapper;

View file

@ -16,6 +16,8 @@ class FindReplaceMenu implements FindReplaceService {
required this.showReplaceMenu,
this.localizations,
required this.style,
this.showRegexButton = true,
this.showCaseSensitiveButton = true,
});
final BuildContext context;
@ -23,6 +25,8 @@ class FindReplaceMenu implements FindReplaceService {
final bool showReplaceMenu;
final FindReplaceLocalizations? localizations;
final FindReplaceStyle style;
final bool showRegexButton;
final bool showCaseSensitiveButton;
final double topOffset = 52;
final double rightOffset = 40;
@ -96,6 +100,8 @@ class FindReplaceMenu implements FindReplaceService {
showReplaceMenu: showReplaceMenu,
localizations: localizations,
style: style,
showRegexButton: showRegexButton,
showCaseSensitiveButton: showCaseSensitiveButton,
),
),
),

View file

@ -1,5 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/find_replace_menu/find_replace_menu_icon_button.dart';
import 'find_replace_menu_icon_button.dart';
import 'package:flutter/material.dart';
const double _iconButtonSize = 30;
@ -14,6 +14,8 @@ class FindAndReplaceMenuWidget extends StatefulWidget {
required this.editorState,
required this.showReplaceMenu,
required this.style,
this.showRegexButton = true,
this.showCaseSensitiveButton = true,
});
final EditorState editorState;
@ -26,6 +28,8 @@ class FindAndReplaceMenuWidget extends StatefulWidget {
///
/// only works for SearchService, not for SearchServiceV2
final FindReplaceStyle style;
final bool showRegexButton;
final bool showCaseSensitiveButton;
/// The localizations of the find and replace menu
final FindReplaceLocalizations? localizations;
@ -42,15 +46,25 @@ class FindAndReplaceMenuWidget extends StatefulWidget {
}
class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
final focusNode = FocusNode();
final replaceFocusNode = FocusNode();
final findController = TextEditingController();
final replaceController = TextEditingController();
String queriedPattern = '';
bool showRegexButton = true;
bool showCaseSensitiveButton = true;
bool showReplaceMenu = false;
late SearchServiceV2 searchService = SearchServiceV2(
late SearchServiceV3 searchService = SearchServiceV3(
editorState: widget.editorState,
);
@override
void initState() {
super.initState();
showReplaceMenu = widget.showReplaceMenu;
showRegexButton = widget.showRegexButton;
showCaseSensitiveButton = widget.showCaseSensitiveButton;
showReplaceMenu = widget.showReplaceMenu;
}
@ -106,6 +120,8 @@ class FindMenu extends StatefulWidget {
required this.style,
required this.searchService,
required this.onShowReplace,
this.showRegexButton = true,
this.showCaseSensitiveButton = true,
});
final EditorState editorState;
@ -128,9 +144,12 @@ class FindMenu extends StatefulWidget {
/// Whether to show the replace menu or not
final bool showReplaceMenu;
final bool showRegexButton;
final bool showCaseSensitiveButton;
final void Function(bool showReplaceMenu) onShowReplace;
final SearchServiceV2 searchService;
final SearchServiceV3 searchService;
@override
State<FindMenu> createState() => _FindMenuState();
@ -146,6 +165,9 @@ class _FindMenuState extends State<FindMenu> {
bool showReplaceMenu = false;
bool caseSensitive = false;
bool showRegexButton = true;
bool showCaseSensitiveButton = true;
@override
void initState() {
super.initState();
@ -153,7 +175,10 @@ class _FindMenuState extends State<FindMenu> {
showReplaceMenu = widget.showReplaceMenu;
caseSensitive = widget.caseSensitive;
widget.searchService.matchedPositions.addListener(_setState);
showRegexButton = widget.showRegexButton;
showCaseSensitiveButton = widget.showCaseSensitiveButton;
widget.searchService.matchWrappers.addListener(_setState);
widget.searchService.currentSelectedIndex.addListener(_setState);
findTextEditingController.addListener(_searchPattern);
@ -165,7 +190,7 @@ class _FindMenuState extends State<FindMenu> {
@override
void dispose() {
widget.searchService.matchedPositions.removeListener(_setState);
widget.searchService.matchWrappers.removeListener(_setState);
widget.searchService.currentSelectedIndex.removeListener(_setState);
widget.searchService.dispose();
findTextEditingController.removeListener(_searchPattern);
@ -178,7 +203,7 @@ class _FindMenuState extends State<FindMenu> {
Widget build(BuildContext context) {
// the selectedIndex from searchService is 0-based
final selectedIndex = widget.searchService.selectedIndex + 1;
final matches = widget.searchService.matchedPositions.value;
final matches = widget.searchService.matchWrappers.value;
return Row(
children: [
// expand/collapse button
@ -230,21 +255,6 @@ class _FindMenuState extends State<FindMenu> {
: '$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'),
@ -268,6 +278,45 @@ class _FindMenuState extends State<FindMenu> {
tooltip: widget.localizations?.close ??
AppFlowyEditorLocalizations.current.closeFind,
),
// regex button
if (showRegexButton)
FindAndReplaceMenuIconButton(
key: const Key('findRegexButton'),
onPressed: () {
setState(() {
widget.searchService.regex = !widget.searchService.regex;
});
_searchPattern();
},
icon: EditorSvg(
name: 'regex',
width: 20,
height: 20,
color: widget.searchService.regex ? Colors.black : Colors.grey,
),
tooltip: AppFlowyEditorLocalizations.current.regex,
),
// case sensitive button
if (showCaseSensitiveButton)
FindAndReplaceMenuIconButton(
key: const Key('caseSensitiveButton'),
onPressed: () {
setState(() {
widget.searchService.caseSensitive =
!widget.searchService.caseSensitive;
});
_searchPattern();
},
icon: EditorSvg(
name: 'case_sensitive',
width: 20,
height: 20,
color: widget.searchService.caseSensitive
? Colors.black
: Colors.grey,
),
tooltip: AppFlowyEditorLocalizations.current.caseSensitive,
),
],
);
}
@ -298,7 +347,7 @@ class ReplaceMenu extends StatefulWidget {
/// The localizations of the find and replace menu
final FindReplaceLocalizations? localizations;
final SearchServiceV2 searchService;
final SearchServiceV3 searchService;
@override
State<ReplaceMenu> createState() => _ReplaceMenuState();

View file

@ -5,7 +5,40 @@ import 'dart:math' as math;
/// `searchMethod(String pattern, String text)`, here `pattern` is the sequence of
/// characters that are to be searched within the `text`.
abstract class SearchAlgorithm {
List<int> searchMethod(String pattern, String text);
Iterable<Match> searchMethod(Pattern pattern, String text);
}
final class BoyerMooreMatch implements Match {
const BoyerMooreMatch(
this.pattern,
this.input,
this.start,
) : end = start + pattern.length;
@override
final int start;
@override
final String input;
@override
final String pattern;
@override
final int end;
@override
final int groupCount = 0;
@override
String operator [](int g) => group(g);
@override
String group(int group) {
if (group != 0) {
throw RangeError.value(group);
}
return pattern;
}
@override
List<String> groups(List<int> groups) => groups.map((e) => group(e)).toList();
}
class BoyerMoore extends SearchAlgorithm {
@ -13,12 +46,20 @@ class BoyerMoore extends SearchAlgorithm {
//It is more efficient than brute force searching because it is able to skip
//characters that will never possibly match with required pattern.
@override
List<int> searchMethod(String pattern, String text) {
List<Match> searchMethod(Pattern pattern, String text) {
if (pattern is String) {
return _searchMethod(pattern, text);
}
throw UnimplementedError();
}
List<Match> _searchMethod(String pattern, String text) {
int m = pattern.length;
int n = text.length;
Map<String, int> badchar = {};
List<int> matches = [];
List<Match> matches = [];
_badCharHeuristic(pattern, m, badchar);
@ -33,7 +74,7 @@ class BoyerMoore extends SearchAlgorithm {
//if pattern is present at current shift, the index will become -1
if (j < 0) {
matches.add(s);
matches.add(BoyerMooreMatch(pattern, text, s));
s += (s + m < n) ? m - (badchar[text[s + m]] ?? -1) : 1;
} else {
s += math.max(1, j - (badchar[text[s + j]] ?? -1));
@ -54,3 +95,10 @@ class BoyerMoore extends SearchAlgorithm {
}
}
}
class DartBuiltIn extends SearchAlgorithm {
@override
Iterable<Match> searchMethod(Pattern pattern, String text) {
return pattern.allMatches(text);
}
}

View file

@ -49,8 +49,10 @@ class SearchService {
for (final n in _getAllNodes()) {
//matches list will contain the offsets where the desired word,
//is found.
List<int> matches =
searchAlgorithm.searchMethod(pattern, n.delta!.toPlainText());
List<int> matches = searchAlgorithm
.searchMethod(pattern, n.delta!.toPlainText())
.map((e) => e.start)
.toList();
//we will store this list of offsets along with their path,
//in a list of positions.
for (int matchedOffset in matches) {

View file

@ -78,10 +78,13 @@ class SearchServiceV2 {
for (final node in nodes) {
if (node.delta != null) {
final text = node.delta!.toPlainText();
List<int> matches = searchAlgorithm.searchMethod(
List<int> matches = searchAlgorithm
.searchMethod(
caseSensitive ? pattern : pattern.toLowerCase(),
caseSensitive ? text : text.toLowerCase(),
);
)
.map((e) => e.start)
.toList();
// we will store this list of offsets along with their path,
// in a list of positions.
for (int matchedOffset in matches) {

View file

@ -0,0 +1,259 @@
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 SearchServiceV3 {
SearchServiceV3({
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<MatchWrapper>> matchWrappers = ValueNotifier([]);
SearchAlgorithm searchAlgorithm = DartBuiltIn();
String targetString = '';
Pattern queriedPattern = RegExp('');
bool _regex = false;
bool get regex => _regex;
set regex(bool value) {
_regex = value;
findAndHighlight(targetString);
}
bool _caseSensitive = false;
bool get caseSensitive => _caseSensitive;
set caseSensitive(bool value) {
_caseSensitive = value;
findAndHighlight(targetString);
}
int _selectedIndex = 0;
int get selectedIndex => _selectedIndex;
set selectedIndex(int index) {
_prevSelectedIndex = _selectedIndex;
_selectedIndex = matchWrappers.value.isEmpty
? -1
: index.clamp(0, matchWrappers.value.length - 1);
currentSelectedIndex.value = _selectedIndex;
}
// only used for scrolling to the first or the last match.
int _prevSelectedIndex = 0;
ValueNotifier<int> currentSelectedIndex = ValueNotifier(0);
Pattern _getPattern(String targetString) {
if (regex) {
return RegExp(targetString, caseSensitive: caseSensitive);
} else {
return RegExp(RegExp.escape(targetString), caseSensitive: caseSensitive);
}
}
String _getRegexReplaced(String replaceText, Match match) {
List<String?> groups = match
.groups(List<int>.generate(match.groupCount + 1, (index) => index));
String replacedText = replaceText;
for (int i = 0; i <= match.groupCount; i++) {
replacedText = replacedText.replaceAll('\\$i', groups[i] ?? '');
}
return replacedText;
}
void dispose() {
matchWrappers.dispose();
currentSelectedIndex.dispose();
}
// Public entry method for _findAndHighlight, do necessary checks
// and clear previous highlights before calling the private method
void findAndHighlight(String target, {bool unHighlight = false}) {
Pattern pattern = _getPattern(target);
if (queriedPattern != pattern) {
// this means we have a new pattern, but before we highlight the new matches,
// lets unhighlight the old pattern
_findAndHighlight(queriedPattern, unHighlight: true);
matchWrappers.value.clear();
queriedPattern = pattern;
targetString = target;
}
if (target.isEmpty) return;
_findAndHighlight(pattern, unHighlight: unHighlight);
}
/// 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(
Pattern pattern, {
bool unHighlight = false,
}) {
matchWrappers.value = _getMatchWrappers(
pattern: pattern,
nodes: editorState.document.root.children,
);
if (matchWrappers.value.isNotEmpty) {
selectedIndex = selectedIndex;
_highlightCurrentMatch(
pattern,
);
} else {
editorState.updateSelectionWithReason(null);
}
}
List<MatchWrapper> _getMatchWrappers({
required Pattern pattern,
Iterable<Node> nodes = const [],
}) {
final List<MatchWrapper> result = [];
for (final node in nodes) {
if (node.delta != null) {
final text = node.delta!.toPlainText();
final matches = searchAlgorithm.searchMethod(pattern, text);
// we will store this list of offsets along with their path,
// in a list of positions.
for (Match match in matches) {
result.add(
MatchWrapper(match, node.path),
);
}
}
result.addAll(
_getMatchWrappers(pattern: pattern, nodes: node.children),
);
}
return result;
}
void _highlightCurrentMatch(
Pattern pattern,
) {
final selection = matchWrappers.value[selectedIndex].selection;
// https://github.com/google/flutter.widgets/issues/151
// there's a bug in the scrollable_positioned_list package
// we can't scroll to the index without animation.
// so we just scroll the position if the index is the first or the last.
final length = matchWrappers.value.length - 1;
if (_prevSelectedIndex != selectedIndex &&
((_prevSelectedIndex == length && selectedIndex == 0) ||
(_prevSelectedIndex == 0 && selectedIndex == length))) {
editorState.scrollService?.jumpTo(selection.start.path.first);
}
editorState.updateSelectionWithReason(
selection,
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 (matchWrappers.value.isEmpty) return;
if (moveUp) {
selectedIndex = selectedIndex <= 0
? matchWrappers.value.length - 1
: selectedIndex - 1;
} else {
selectedIndex = selectedIndex >= matchWrappers.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.toString().isEmpty ||
matchWrappers.value.isEmpty) {
return;
}
final matchWrap = matchWrappers.value[selectedIndex];
final node = editorState.getNodeAtPath(matchWrap.path)!;
final String replaced;
if (regex) {
replaced = _getRegexReplaced(replaceText, matchWrap.match);
} else {
replaced = replaceText;
}
final transaction = editorState.transaction
..replaceText(
node,
matchWrap.selection.start.offset,
matchWrap.selection.length,
replaced,
);
await editorState.apply(transaction);
matchWrappers.value.clear();
_findAndHighlight(queriedPattern);
}
/// Replaces all the found occurrences of pattern with replaceText
void replaceAllMatches(String replaceText) {
if (replaceText.isEmpty ||
queriedPattern.toString().isEmpty ||
matchWrappers.value.isEmpty) {
return;
}
// _highlightAllMatches(queriedPattern.length, unHighlight: true);
for (final matchWrap in matchWrappers.value.reversed) {
final node = editorState.getNodeAtPath(matchWrap.path)!;
final String replaced;
if (regex) {
replaced = _getRegexReplaced(replaceText, matchWrap.match);
} else {
replaced = replaceText;
}
final transaction = editorState.transaction
..replaceText(
node,
matchWrap.selection.startIndex,
matchWrap.selection.length,
replaced,
);
editorState.apply(transaction);
}
matchWrappers.value.clear();
}
}
class MatchWrapper {
MatchWrapper(this.match, this.path);
final Match match;
final Path path;
Selection get selection => Selection(
start: Position(path: path, offset: match.start),
end: Position(path: path, offset: match.end),
);
}

View file

@ -49,6 +49,7 @@ class MessageLookup extends MessageLookupByLibrary {
"bold": MessageLookupByLibrary.simpleMessage("Bold"),
"bulletedList": MessageLookupByLibrary.simpleMessage("Bulleted List"),
"cancel": MessageLookupByLibrary.simpleMessage("Cancel"),
"caseSensitive": MessageLookupByLibrary.simpleMessage("Case sensitive"),
"checkbox": MessageLookupByLibrary.simpleMessage("Checkbox"),
"chooseImage": MessageLookupByLibrary.simpleMessage("Choose an image"),
"clearHighlightColor":
@ -118,6 +119,7 @@ class MessageLookup extends MessageLookupByLibrary {
"paste": MessageLookupByLibrary.simpleMessage("Paste"),
"previousMatch": MessageLookupByLibrary.simpleMessage("Previous match"),
"quote": MessageLookupByLibrary.simpleMessage("Quote"),
"regex": MessageLookupByLibrary.simpleMessage("Regex"),
"removeLink": MessageLookupByLibrary.simpleMessage("Remove link"),
"replace": MessageLookupByLibrary.simpleMessage("Replace"),
"replaceAll": MessageLookupByLibrary.simpleMessage("Replace all"),

View file

@ -38,6 +38,7 @@ class MessageLookup extends MessageLookupByLibrary {
"bold": MessageLookupByLibrary.simpleMessage("粗体"),
"bulletedList": MessageLookupByLibrary.simpleMessage("无序列表"),
"cancel": MessageLookupByLibrary.simpleMessage("取消"),
"caseSensitive": MessageLookupByLibrary.simpleMessage("区分大小写"),
"checkbox": MessageLookupByLibrary.simpleMessage("复选框"),
"chooseImage": MessageLookupByLibrary.simpleMessage("选择图片文件"),
"clearHighlightColor": MessageLookupByLibrary.simpleMessage("清除高亮颜色"),
@ -102,6 +103,7 @@ class MessageLookup extends MessageLookupByLibrary {
"paste": MessageLookupByLibrary.simpleMessage("粘贴"),
"previousMatch": MessageLookupByLibrary.simpleMessage("上一匹配项"),
"quote": MessageLookupByLibrary.simpleMessage("引文"),
"regex": MessageLookupByLibrary.simpleMessage("正则表达式"),
"removeLink": MessageLookupByLibrary.simpleMessage("移除链接"),
"replace": MessageLookupByLibrary.simpleMessage("替换"),
"replaceAll": MessageLookupByLibrary.simpleMessage("替换全部"),

View file

@ -38,6 +38,7 @@ class MessageLookup extends MessageLookupByLibrary {
"bold": MessageLookupByLibrary.simpleMessage("粗體"),
"bulletedList": MessageLookupByLibrary.simpleMessage("無序列表"),
"cancel": MessageLookupByLibrary.simpleMessage("取消"),
"caseSensitive": MessageLookupByLibrary.simpleMessage("區分大小寫"),
"checkbox": MessageLookupByLibrary.simpleMessage("核取方塊"),
"chooseImage": MessageLookupByLibrary.simpleMessage("選取圖像檔案"),
"clearHighlightColor": MessageLookupByLibrary.simpleMessage("清除高亮顏色"),
@ -102,6 +103,7 @@ class MessageLookup extends MessageLookupByLibrary {
"paste": MessageLookupByLibrary.simpleMessage("貼上"),
"previousMatch": MessageLookupByLibrary.simpleMessage("上一相符項"),
"quote": MessageLookupByLibrary.simpleMessage("引文"),
"regex": MessageLookupByLibrary.simpleMessage("正規表示式"),
"removeLink": MessageLookupByLibrary.simpleMessage("移除連結"),
"replace": MessageLookupByLibrary.simpleMessage("取代"),
"replaceAll": MessageLookupByLibrary.simpleMessage("取代全部"),

View file

@ -941,6 +941,26 @@ class AppFlowyEditorLocalizations {
);
}
/// `Regex`
String get regex {
return Intl.message(
'Regex',
name: 'regex',
desc: '',
args: [],
);
}
/// `Case sensitive`
String get caseSensitive {
return Intl.message(
'Case sensitive',
name: 'caseSensitive',
desc: '',
args: [],
);
}
/// `Upload Image`
String get uploadImage {
return Intl.message(

View file

@ -306,5 +306,147 @@ void main() async {
expect(node!.delta!.toPlainText(), replacePattern * 5);
await editor.dispose();
});
testWidgets('replace all regex matches', (tester) async {
const patternToBeFound = 'a[a-z]p';
const replacePattern = 'axp';
const expectedText =
'Welcome to the Appflowy exaxple axp, an axpha-level editor for caxpuses 😁';
const replaceAllBtn = Key('replaceAllButton');
const regexButton = Key('findRegexButton');
const caseSensitiveButton = Key('caseSensitiveButton');
final editor = tester.editor;
editor.addParagraphs(1, initialText: regexTarget);
await editor.startTesting();
await editor.updateSelection(Selection.single(path: [0], startOffset: 0));
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
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();
await tester.tap(find.byKey(regexButton));
await tester.pumpAndSettle();
await tester.tap(find.byKey(caseSensitiveButton));
await tester.pumpAndSettle();
//now we input some text into the replace text field and try replace all
await enterInputIntoFindDialog(
tester,
replacePattern,
isReplaceField: true,
);
await tester.tap(find.byKey(replaceAllBtn));
await tester.pumpAndSettle();
//all matches should be replaced
final node = editor.nodeAtPath([0]);
expect(node!.delta!.toPlainText(), expectedText);
await editor.dispose();
});
testWidgets('replace all regex matches with case insensitive',
(tester) async {
const patternToBeFound = 'a[a-z]p';
const replacePattern = 'axp';
const expectedText =
'Welcome to the axpflowy exaxple axp, an axpha-level editor for caxpuses 😁';
const replaceAllBtn = Key('replaceAllButton');
const regexButton = Key('findRegexButton');
final editor = tester.editor;
editor.addParagraphs(1, initialText: regexTarget);
await editor.startTesting();
await editor.updateSelection(Selection.single(path: [0], startOffset: 0));
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
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();
await tester.tap(find.byKey(regexButton));
await tester.pumpAndSettle();
//now we input some text into the replace text field and try replace all
await enterInputIntoFindDialog(
tester,
replacePattern,
isReplaceField: true,
);
await tester.tap(find.byKey(replaceAllBtn));
await tester.pumpAndSettle();
//all matches should be replaced
final node = editor.nodeAtPath([0]);
expect(node!.delta!.toPlainText(), expectedText);
await editor.dispose();
});
testWidgets('replace all regex matches with backreference', (tester) async {
const patternToBeFound = 'a([a-z])p';
const replacePattern = r'b\1q';
const expectedText =
'Welcome to the Appflowy exbmqle bpq, an blqha-level editor for cbmquses 😁';
const replaceAllBtn = Key('replaceAllButton');
const regexButton = Key('findRegexButton');
const caseSensitiveButton = Key('caseSensitiveButton');
final editor = tester.editor;
editor.addParagraphs(1, initialText: regexTarget);
await editor.startTesting();
await editor.updateSelection(Selection.single(path: [0], startOffset: 0));
await pressFindAndReplaceCommand(editor, openReplace: true);
await tester.pumpAndSettle();
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();
await tester.tap(find.byKey(regexButton));
await tester.pumpAndSettle();
await tester.tap(find.byKey(caseSensitiveButton));
await tester.pumpAndSettle();
//now we input some text into the replace text field and try replace all
await enterInputIntoFindDialog(
tester,
replacePattern,
isReplaceField: true,
);
await tester.tap(find.byKey(replaceAllBtn));
await tester.pumpAndSettle();
//all matches should be replaced
final node = editor.nodeAtPath([0]);
expect(node!.delta!.toPlainText(), expectedText);
await editor.dispose();
});
});
}

View file

@ -9,6 +9,8 @@ import 'package:flutter_test/flutter_test.dart';
import '../infra/testable_editor.dart';
const text = 'Welcome to Appflowy 😁';
const regexTarget =
'Welcome to the Appflowy example app, an alpha-level editor for campuses 😁';
const Color kSelectedHighlightColor = Colors.yellow;
const Color kUnselectedHighlightColor = Colors.lightGreen;
String kSelectedHCHex = kSelectedHighlightColor.toHex();

View file

@ -3,17 +3,18 @@ import 'package:appflowy_editor/src/editor/find_replace_menu/search_algorithm.da
void main() {
group('search_algorithm_test.dart', () {
late SearchAlgorithm searchAlgorithm;
late SearchAlgorithm algorithm;
setUp(() {
searchAlgorithm = BoyerMoore();
algorithm = DartBuiltIn();
});
test('search algorithm returns the index of the only found pattern', () {
const pattern = 'Appflowy';
const text = 'Welcome to Appflowy 😁';
List<int> result = searchAlgorithm.searchMethod(pattern, text);
List<int> result =
algorithm.searchMethod(pattern, text).map((e) => e.start).toList();
expect(result, [11]);
});
@ -29,7 +30,8 @@ grouped by property. Design and modify Appflowy your way with an
open core codebase. Appflowy is built with Flutter and Rust.
''';
List<int> result = searchAlgorithm.searchMethod(pattern, text);
List<int> result =
algorithm.searchMethod(pattern, text).map((e) => e.start).toList();
expect(result, [11, 24, 80, 196, 324, 371]);
});
@ -37,7 +39,7 @@ open core codebase. Appflowy is built with Flutter and Rust.
const pattern = 'Flutter';
const text = 'Welcome to Appflowy 😁';
final result = searchAlgorithm.searchMethod(pattern, text);
final result = algorithm.searchMethod(pattern, text);
expect(result, []);
});
@ -46,7 +48,8 @@ open core codebase. Appflowy is built with Flutter and Rust.
const pattern = '😁';
const text = 'Welcome to Appflowy 😁';
List<int> result = searchAlgorithm.searchMethod(pattern, text);
List<int> result =
algorithm.searchMethod(pattern, text).map((e) => e.start).toList();
expect(result, [20]);
});
@ -56,7 +59,8 @@ open core codebase. Appflowy is built with Flutter and Rust.
const pattern = 'App';
const text = 'Welcome to Appflowy 😁';
List<int> result = searchAlgorithm.searchMethod(pattern, text);
List<int> result =
algorithm.searchMethod(pattern, text).map((e) => e.start).toList();
expect(result, [11]);
});
@ -64,8 +68,29 @@ open core codebase. Appflowy is built with Flutter and Rust.
const pattern = 'APPFLOWY';
const text = 'Welcome to Appflowy 😁';
List<int> result = searchAlgorithm.searchMethod(pattern, text);
List<int> result =
algorithm.searchMethod(pattern, text).map((e) => e.start).toList();
expect(result, []);
});
test('case insensitive search', () async {
final pattern = RegExp('APPFLOWY', caseSensitive: false);
const text = 'Welcome to Appflowy 😁';
List<int> result =
algorithm.searchMethod(pattern, text).map((e) => e.start).toList();
expect(result, [11]);
});
test('regex search', () async {
final pattern = RegExp('a[a-z]p', caseSensitive: false);
const text = 'Welcome to Appflowy example app 😁';
Iterable<Match> result = algorithm.searchMethod(pattern, text);
final starts = result.map((e) => e.start).toList();
final ends = result.map((e) => e.end).toList();
expect(starts, [11, 22, 28]);
expect(ends, [14, 25, 31]);
});
});
}