diff --git a/README.md b/README.md index 73195846..a4d3219a 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ The `QuillToolbar` and `QuillEditor` widgets let you customize a lot of things - [Font Size](./doc/configurations/font_size.md) - [Font Family](#font-family) - [Custom Toolbar buttons](./doc/configurations/custom_buttons.md) +- [Search](./doc/configurations/search.md) ### 🖋 Font Family diff --git a/doc/configurations/search.md b/doc/configurations/search.md new file mode 100644 index 00000000..78aba472 --- /dev/null +++ b/doc/configurations/search.md @@ -0,0 +1,68 @@ +# Search + +You can search the text of your document using the search toolbar button. +Enter the text and use the up/down buttons to move to the previous/next selection. +Use the 3 vertical dots icon to turn on case-sensitivity or whole word constraints. + +## Search configuration options + +By default, the content of Embed objects are not searched. +You can enable search by setting the [searchEmbedMode] in searchConfigurations: + +```dart + MyQuillEditor( + controller: _controller, + configurations: QuillEditorConfigurations( + searchConfigurations: const QuillSearchConfigurations( + searchEmbedMode: SearchEmbedMode.plainText, + ), + ), + ... + ), +``` + +### SearchEmbedMode.none (default option) + +Embed objects will not be included in searches. + +### SearchEmbedMode.rawData + +This is the simplest search option when your Embed objects use simple text that is also displayed to the user. +This option allows searching within custom Embed objects using the node's raw data [Embeddable.data]. + +### SearchEmbedMode.plainText + +This option is best for complex Embeds where the raw data contains text that is not visible to the user and/or contains textual data that is not suitable for searching. +For example, searching for '2024' would not be meaningful if the raw data is the full path of an image object (such as /user/temp/20240125/myimage.png). +In this case the image would be shown as a search hit but the user would not know why. + +This option allows searching within custom Embed objects using an override to the [toPlainText] method. + +```dart + class MyEmbedBuilder extends EmbedBuilder { + + @override + String toPlainText(Embed node) { + /// Convert [node] to the text that can be searched. + /// For example: convert to MyEmbeddable and use the + /// properties to return the searchable text. + final m = MyEmbeddable(node.value.data); + return '${m.property1}\t${m.property2}'; + } + ... +``` +If [toPlainText] is not overridden, the base class implementation returns [Embed.kObjectReplacementCharacter] which is not searchable. + +### Strategy for mixed complex and simple Embed objects + +Select option [SearchEmbedMode.plainText] and override [toPlainText] to provide the searchable text. For your simple Embed objects provide the following override: + +```dart + class MySimpleEmbedBuilder extends EmbedBuilder { + + @override + String toPlainText(Embed node) { + return node.value.data; + } + ... +``` diff --git a/example/lib/screens/quill/quill_screen.dart b/example/lib/screens/quill/quill_screen.dart index e242ab71..b47a8c3d 100644 --- a/example/lib/screens/quill/quill_screen.dart +++ b/example/lib/screens/quill/quill_screen.dart @@ -104,6 +104,9 @@ class _QuillScreenState extends State { child: MyQuillEditor( controller: _controller, configurations: QuillEditorConfigurations( + searchConfigurations: const QuillSearchConfigurations( + searchEmbedMode: SearchEmbedMode.plainText, + ), sharedConfigurations: _sharedConfigurations, ), scrollController: _editorScrollController, diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 364a8c7f..0054ec3f 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -7,6 +7,7 @@ import '../common/structs/offset_value.dart'; import '../common/structs/segment_leaf_node.dart'; import '../delta/delta_x.dart'; import '../editor/config/editor_configurations.dart'; +import '../editor/config/search_configurations.dart'; import '../editor/embed/embed_editor_builder.dart'; import '../rules/rule.dart'; import 'attribute.dart'; @@ -191,31 +192,21 @@ class Document { while ((res.node as Line).length == 1 && index > 0) { res = queryChild(--index); } - // - var style = (res.node as Line).collectStyle(res.offset, 0); - final remove = {}; - final add = {}; - for (final attr in style.attributes.values) { - if (!Attribute.inlineKeys.contains(attr.key)) { - if (!current.containsKey(attr.key)) { - remove.add(attr); - } else { - /// Trap for type of block attribute is changing - final curAttr = current.attributes[attr.key]; - if (curAttr!.value != attr.value) { - remove.add(attr); - add[curAttr.key] = curAttr; - } - } + // Get inline attributes from previous line + final prev = (res.node as Line).collectStyle(res.offset, 0); + final attributes = {}; + for (final attr in prev.attributes.values) { + if (attr.scope == AttributeScope.inline) { + attributes[attr.key] = attr; } } - if (remove.isNotEmpty) { - style = style.removeAll(remove); - } - if (add.isNotEmpty) { - style.attributes.addAll(add); + // Combine with block attributes from current line + for (final attr in current.attributes.values) { + if (attr.scope == AttributeScope.block) { + attributes[attr.key] = attr; + } } - return style; + return Style.attr(attributes); } // final style = (res.node as Line).collectStyle(res.offset - 1, 0); @@ -259,6 +250,8 @@ class Document { _editorConfigurations ?? const QuillEditorConfigurations(); set editorConfigurations(QuillEditorConfigurations? value) => _editorConfigurations = value; + QuillSearchConfigurations get searchConfigurations => + editorConfigurations.searchConfigurations; /// Returns plain text within the specified text range. String getPlainText(int index, int len, [bool includeEmbeds = false]) { @@ -287,16 +280,15 @@ class Document { bool caseSensitive = false, bool wholeWord = false, }) { - final searchEmbedContent = editorConfigurations.searchEmbedContent; final matches = []; for (final node in _root.children) { if (node is Line) { - _searchLine(substring, caseSensitive, wholeWord, searchEmbedContent, - node, matches); + _searchLine(substring, caseSensitive, wholeWord, + searchConfigurations.searchEmbedMode, node, matches); } else if (node is Block) { for (final line in Iterable.castFrom(node.children)) { - _searchLine(substring, caseSensitive, wholeWord, searchEmbedContent, - line, matches); + _searchLine(substring, caseSensitive, wholeWord, + searchConfigurations.searchEmbedMode, line, matches); } } else { throw StateError('Unreachable.'); @@ -309,7 +301,7 @@ class Document { String substring, bool caseSensitive, bool wholeWord, - bool searchEmbedContent, + SearchEmbedMode searchEmbedMode, Line line, List matches, ) { @@ -328,29 +320,17 @@ class Document { matches.add(index + line.documentOffset); } // - if (searchEmbedContent && line.hasEmbed) { + if (line.hasEmbed && searchEmbedMode != SearchEmbedMode.none) { Node? node = line.children.first; while (node != null) { if (node is Embed) { final ofs = node.offset; - EmbedBuilder? builder; - if (editorConfigurations.embedBuilders != null) { - // Find the builder for this embed - for (final b in editorConfigurations.embedBuilders!) { - if (b.key == node.value.type) { - builder = b; - break; - } - } - } - builder ??= editorConfigurations.unknownEmbedBuilder; - // Get searchable text for this embed - var embedText = builder?.toPlainText(node); - if (editorConfigurations.searchEmbedRawData == true && - (embedText == null || - embedText == Embed.kObjectReplacementCharacter)) { - embedText = node.value.data.toString(); - } + final embedText = switch (searchEmbedMode) { + SearchEmbedMode.rawData => node.value.data.toString(), + SearchEmbedMode.plainText => _embedSearchText(node), + SearchEmbedMode.none => null, + }; + // if (embedText?.contains(searchExpression) == true) { final documentOffset = line.documentOffset + ofs; final index = matches.indexWhere((e) => e > documentOffset); @@ -366,6 +346,22 @@ class Document { } } + String? _embedSearchText(Embed node) { + EmbedBuilder? builder; + if (editorConfigurations.embedBuilders != null) { + // Find the builder for this embed + for (final b in editorConfigurations.embedBuilders!) { + if (b.key == node.value.type) { + builder = b; + break; + } + } + } + builder ??= editorConfigurations.unknownEmbedBuilder; + // Get searchable text for this embed + return builder?.toPlainText(node); + } + /// Given offset, find its leaf node in document SegmentLeafNode querySegmentLeafNode(int offset) { final result = queryChild(offset); diff --git a/lib/src/editor/config/editor_configurations.dart b/lib/src/editor/config/editor_configurations.dart index 29925ff4..425192f6 100644 --- a/lib/src/editor/config/editor_configurations.dart +++ b/lib/src/editor/config/editor_configurations.dart @@ -15,6 +15,7 @@ import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; import '../widgets/link.dart'; import 'element_options.dart'; +import 'search_configurations.dart'; export 'element_options.dart'; @@ -57,8 +58,7 @@ class QuillEditorConfigurations extends Equatable { this.enableMarkdownStyleConversion = true, this.embedBuilders, this.unknownEmbedBuilder, - this.searchEmbedContent = true, - this.searchEmbedRawData = false, + this.searchConfigurations = const QuillSearchConfigurations(), this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.customStyleBuilder, this.customRecognizerBuilder, @@ -283,13 +283,7 @@ class QuillEditorConfigurations extends Equatable { final CustomStyleBuilder? customStyleBuilder; final CustomRecognizerBuilder? customRecognizerBuilder; - /// Search options for embed objects - enable [searchEmbedContent], disable [searchEmbedRawData] - /// - /// [searchEmbedContent] enables searching within embed objects using the [EmbedBuilder.toPlainText] override to provide the searchable text. Default is true. - /// [searchEmbedRawData] enables searching the raw data of the embed object if [EmbedBuilder.toPlainText] is not overridden. - /// Default is false since the raw data is not usually visible to a user and it will not be obvious why the embed object was selected. - final bool searchEmbedContent; - final bool searchEmbedRawData; + final QuillSearchConfigurations searchConfigurations; /// Delegate function responsible for showing menu with link actions on /// mobile platforms (iOS, Android). @@ -432,8 +426,7 @@ class QuillEditorConfigurations extends Equatable { ValueChanged? onLaunchUrl, Iterable? embedBuilders, EmbedBuilder? unknownEmbedBuilder, - bool? searchEmbedContent, - bool? searchEmbedRawData, + QuillSearchConfigurations? searchConfigurations, CustomStyleBuilder? customStyleBuilder, CustomRecognizerBuilder? customRecognizerBuilder, LinkActionPickerDelegate? linkActionPickerDelegate, @@ -493,8 +486,7 @@ class QuillEditorConfigurations extends Equatable { onLaunchUrl: onLaunchUrl ?? this.onLaunchUrl, embedBuilders: embedBuilders ?? this.embedBuilders, unknownEmbedBuilder: unknownEmbedBuilder ?? this.unknownEmbedBuilder, - searchEmbedContent: searchEmbedContent ?? this.searchEmbedContent, - searchEmbedRawData: searchEmbedRawData ?? this.searchEmbedRawData, + searchConfigurations: searchConfigurations ?? this.searchConfigurations, customStyleBuilder: customStyleBuilder ?? this.customStyleBuilder, customRecognizerBuilder: customRecognizerBuilder ?? this.customRecognizerBuilder, diff --git a/lib/src/editor/config/search_configurations.dart b/lib/src/editor/config/search_configurations.dart new file mode 100644 index 00000000..42d716b1 --- /dev/null +++ b/lib/src/editor/config/search_configurations.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart' show immutable; + +enum SearchEmbedMode { + /// No search within Embed nodes. + none, + // Searches within Embed nodes using the nodes raw data [Embeddable.data.toString()] + rawData, + + /// Searches within Embed nodes using override to [EmbedBuilder.toPlainText] + plainText, +} + +/// The configurations for the quill editor widget of flutter quill +@immutable +class QuillSearchConfigurations { + const QuillSearchConfigurations({ + this.searchEmbedMode = SearchEmbedMode.none, + }); + + /// Search options for embed objects + /// + /// [SearchEmbedMode.none] disables searching within embed objects. + /// [SearchEmbedMode.rawData] searches the Embed node using the raw data. + /// [SearchEmbedMode.plainText] searches the Embed node using the [EmbedBuilder.toPlainText] override. + final SearchEmbedMode searchEmbedMode; + + /// Future search options + /// + /// [rememberLastSearch] - would recall the last search text used. + /// [enableSearchHistory] - would allow selection of previous searches. + + QuillSearchConfigurations copyWith({ + SearchEmbedMode? searchEmbedMode, + }) { + return QuillSearchConfigurations( + searchEmbedMode: searchEmbedMode ?? this.searchEmbedMode, + ); + } +} diff --git a/lib/src/editor_toolbar_controller_shared/quill_configurations.dart b/lib/src/editor_toolbar_controller_shared/quill_configurations.dart index bb5b5d1f..faa1365f 100644 --- a/lib/src/editor_toolbar_controller_shared/quill_configurations.dart +++ b/lib/src/editor_toolbar_controller_shared/quill_configurations.dart @@ -1,5 +1,6 @@ export '../controller/quill_controller_configurations.dart'; export '../editor/config/editor_configurations.dart'; +export '../editor/config/search_configurations.dart'; export '../editor_toolbar_shared/config/quill_shared_configurations.dart'; export '../toolbar/config/simple_toolbar_configurations.dart'; export '../toolbar/config/toolbar_configurations.dart'; diff --git a/lib/src/toolbar/buttons/search/search_dialog.dart b/lib/src/toolbar/buttons/search/search_dialog.dart index d1ee9f63..a5cc7f7f 100644 --- a/lib/src/toolbar/buttons/search/search_dialog.dart +++ b/lib/src/toolbar/buttons/search/search_dialog.dart @@ -258,17 +258,21 @@ class QuillToolbarSearchDialogState extends State { } void _findText() { + void clearSelection() { + widget.controller.updateSelection( + TextSelection( + baseOffset: widget.controller.selection.baseOffset, + extentOffset: widget.controller.selection.baseOffset, + ), + ChangeSource.local, + ); + } + if (_text.isEmpty) { setState(() { _offsets = []; _index = 0; - widget.controller.updateSelection( - TextSelection( - baseOffset: widget.controller.selection.baseOffset, - extentOffset: widget.controller.selection.baseOffset, - ), - ChangeSource.local, - ); + clearSelection(); }); return; } @@ -280,7 +284,9 @@ class QuillToolbarSearchDialogState extends State { wholeWord: _wholeWord, ); _index = 0; - if (_offsets.isNotEmpty) { + if (_offsets.isEmpty) { + clearSelection(); + } else { // Select the next hit position for (var n = 0; n < _offsets.length; n++) { if (_offsets[n] >= currPos) { diff --git a/test/document/document_search_test.dart b/test/document/document_search_test.dart new file mode 100644 index 00000000..b2f482df --- /dev/null +++ b/test/document/document_search_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/quill_delta.dart'; +import 'package:test/test.dart'; + +class TestTimeStampEmbed extends Embeddable { + const TestTimeStampEmbed( + String value, + ) : super(timeStampType, value); + + static const String timeStampType = 'timeStamp'; +} + +class TestTimeStampEmbedBuilderWidget extends EmbedBuilder { + const TestTimeStampEmbedBuilderWidget(); + + @override + String get key => 'timeStamp'; + + @override + String toPlainText(Embed node) { + return node.value.data.split(' ')[0]; // return date component + } + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + bool inline, + TextStyle textStyle, + ) { + return Text(node.value.data); + } +} + +class TestUnknownEmbedBuilder extends EmbedBuilder { + const TestUnknownEmbedBuilder(); + + @override + String get key => 'unknown'; + + @override + String toPlainText(Embed node) { + return node.value.data.toString().substring(0, 5); + } + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + bool inline, + TextStyle textStyle, + ) { + return Text(node.value.data); + } +} + +void main() { + group('search plain', () { + test('search plain', () { + final delta = Delta() + ..insert('Abc de\nfGhi') + ..insert('kl', {'bold': true}) + ..insert('demnoDe\n match whole word who\n'); + final document = Document.fromDelta(delta); + + expect(document.search('de'), [4, 13, 18]); + expect(document.search('lde'), [12]); + expect(document.search('a'), [0, 23]); + + expect(document.search('de', caseSensitive: true), [4, 13]); + expect(document.search('De', caseSensitive: true), [18]); + + expect(document.search('who'), [28, 39]); + expect(document.search('whole', wholeWord: true), [28]); + expect(document.search('who', wholeWord: true), [39]); + }); + + test('search embed', () { + final delta = Delta() + ..insert('Test ') + ..insert({ + 'image': 'https://unknown08.com/7900d52.png' + }, { + 'width': '230', + 'style': {'display': 'block', 'margin': 'auto'} + }) + ..insert('\n') + ..insert({'timeStamp': '2024-08-03 18:03:37.790068'}) + ..insert('\n'); + final document = Document.fromDelta(delta); + + /// Default does not search embeds + expect(document.search('2024'), [], reason: 'Does not search embeds'); + + /// Test rawData mode + document.editorConfigurations = const QuillEditorConfigurations( + searchConfigurations: QuillSearchConfigurations( + searchEmbedMode: SearchEmbedMode.rawData)); + expect(document.search('18'), [7], reason: 'raw data finds timeStamp'); + expect(document.search('d52'), [5], reason: 'raw data finds image'); + expect(document.search('08'), [5, 7], + reason: 'raw data finds both embeds'); + // + document.editorConfigurations = const QuillEditorConfigurations( + searchConfigurations: QuillSearchConfigurations( + searchEmbedMode: SearchEmbedMode.plainText)); + expect(document.search('2024'), [], reason: 'No embed builders'); + + /// Test plainText mode + document.editorConfigurations = const QuillEditorConfigurations( + searchConfigurations: QuillSearchConfigurations( + searchEmbedMode: SearchEmbedMode.plainText), + embedBuilders: [ + TestTimeStampEmbedBuilderWidget(), + ], + ); + expect(document.search('2024'), [7], + reason: 'timeStamp embed builder overrides toPlainText'); + expect(document.search('18'), [], + reason: 'timeStamp overrides toPlainText returns date not time'); + expect(document.search('08'), [7], + reason: 'image does not override toPlainText'); + + /// Test unknownEmbedBuilder + document.editorConfigurations = const QuillEditorConfigurations( + searchConfigurations: QuillSearchConfigurations( + searchEmbedMode: SearchEmbedMode.plainText), + embedBuilders: [ + TestTimeStampEmbedBuilderWidget(), + ], + unknownEmbedBuilder: TestUnknownEmbedBuilder()); + expect(document.search('7900'), [], + reason: + 'image not found because unknown returns first 5 chars of rawData'); + expect(document.search('https'), [5], + reason: + 'image found because unknown returns first 5 chars of rawData'); + expect(document.search('http'), [5]); + }); + }); +} diff --git a/test/document/document_test.dart b/test/document/document_test.dart index f1fef3a1..aef3ac16 100644 --- a/test/document/document_test.dart +++ b/test/document/document_test.dart @@ -8,14 +8,13 @@ void main() { /// Changing the format value updates the document but must also update the toolbar button state /// by ensuring the collectStyles method returns the attribute selected for the newly entered line. test('Change block value type', () { - void doTest(Map start, Attribute attr, - Map change) { + void doTest(Map start, Attribute attr) { /// Create a document with 2 lines of block attribute using [start] /// Change the format of the last line using [attr] and verify [change] final delta = Delta() ..insert('A') ..insert('\n', start) - ..insert('B') + ..insert('B', {'bold': true}) ..insert('\n', start); final document = Document.fromDelta(delta) @@ -28,7 +27,7 @@ void main() { Delta() ..insert('A') ..insert('\n', start) - ..insert('B') + ..insert('B', {'bold': true}) ..insert('\n\n', start)); /// Change format of last (empty) line @@ -38,24 +37,23 @@ void main() { Delta() ..insert('A') ..insert('\n', start) - ..insert('B') + ..insert('B', {'bold': true}) ..insert('\n', start) - ..insert('\n', change), + ..insert('\n', {attr.key: attr.value}), reason: 'document updated'); /// Verify that the reported style reflects the newly formatted state - expect(document.collectStyle(4, 0), Style.attr({attr.key: attr}), + expect(document.collectStyle(4, 0), + Style.attr({'bold': Attribute.bold, attr.key: attr}), reason: 'collectStyle reporting correct attribute'); } - doTest({'list': 'ordered'}, const ListAttribute('bullet'), - {'list': 'bullet'}); - doTest({'list': 'checked'}, const ListAttribute('bullet'), - {'list': 'bullet'}); - doTest({'align': 'center'}, const AlignAttribute('right'), - {'align': 'right'}); - doTest({'align': 'left'}, const AlignAttribute('center'), - {'align': 'center'}); + doTest({'list': 'ordered'}, const ListAttribute('bullet')); + doTest({'list': 'checked'}, const ListAttribute('bullet')); + doTest({}, const ListAttribute('bullet')); + doTest({'align': 'center'}, const AlignAttribute('right')); + doTest({'align': 'left'}, const AlignAttribute('center')); + doTest({}, const AlignAttribute('center')); }); /// Enter key inserts newline as plain text without inline styles.