Enhancement: Search within Embed objects (#2090)

pull/2093/head v10.1.8
AtlasAutocode 8 months ago committed by GitHub
parent 80ee3f4e30
commit 40e18b2706
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      README.md
  2. 68
      doc/configurations/search.md
  3. 3
      example/lib/screens/quill/quill_screen.dart
  4. 5
      lib/src/controller/quill_controller.dart
  5. 100
      lib/src/document/document.dart
  6. 6
      lib/src/editor/config/editor_configurations.dart
  7. 39
      lib/src/editor/config/search_configurations.dart
  8. 1
      lib/src/editor_toolbar_controller_shared/quill_configurations.dart
  9. 46
      lib/src/toolbar/buttons/search/search_dialog.dart
  10. 146
      test/document/document_search_test.dart
  11. 28
      test/document/document_test.dart

@ -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

@ -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;
}
...
```

@ -104,6 +104,9 @@ class _QuillScreenState extends State<QuillScreen> {
child: MyQuillEditor(
controller: _controller,
configurations: QuillEditorConfigurations(
searchConfigurations: const QuillSearchConfigurations(
searchEmbedMode: SearchEmbedMode.plainText,
),
sharedConfigurations: _sharedConfigurations,
),
scrollController: _editorScrollController,

@ -60,7 +60,7 @@ class QuillController extends ChangeNotifier {
QuillEditorConfigurations get editorConfigurations =>
_editorConfigurations ?? const QuillEditorConfigurations();
set editorConfigurations(QuillEditorConfigurations? value) =>
_editorConfigurations = value;
_editorConfigurations = document.editorConfigurations = value;
/// Toolbar configurations
///
@ -78,6 +78,7 @@ class QuillController extends ChangeNotifier {
set document(Document doc) {
_document = doc;
_document.editorConfigurations = editorConfigurations;
// Prevent the selection from
_selection = const TextSelection(baseOffset: 0, extentOffset: 0);
@ -520,7 +521,7 @@ class QuillController extends ChangeNotifier {
/// Get the text for the selected region and expand the content of Embedded objects.
_pastePlainText = document.getPlainText(
selection.start, selection.end - selection.start, editorConfigurations);
selection.start, selection.end - selection.start, true);
/// Get the internal representation so it can be pasted into a QuillEditor with style retained.
_pasteStyleAndEmbed = getAllIndividualSelectionStylesAndEmbed();

@ -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 = <Attribute>{};
final add = <String, Attribute>{};
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 = <String, Attribute>{};
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);
@ -250,10 +241,23 @@ class Document {
return (res.node as Line).collectAllStylesWithOffsets(res.offset, len);
}
/// Editor configurations
///
/// Caches configuration set in QuillController.
/// Allows access to embedBuilders and search configurations
QuillEditorConfigurations? _editorConfigurations;
QuillEditorConfigurations get editorConfigurations =>
_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, [QuillEditorConfigurations? config]) {
String getPlainText(int index, int len, [bool includeEmbeds = false]) {
final res = queryChild(index);
return (res.node as Line).getPlainText(res.offset, len, config);
return (res.node as Line).getPlainText(
res.offset, len, includeEmbeds ? editorConfigurations : null);
}
/// Returns [Line] located at specified character [offset].
@ -279,10 +283,12 @@ class Document {
final matches = <int>[];
for (final node in _root.children) {
if (node is Line) {
_searchLine(substring, caseSensitive, wholeWord, node, matches);
_searchLine(substring, caseSensitive, wholeWord,
searchConfigurations.searchEmbedMode, node, matches);
} else if (node is Block) {
for (final line in Iterable.castFrom<dynamic, Line>(node.children)) {
_searchLine(substring, caseSensitive, wholeWord, line, matches);
_searchLine(substring, caseSensitive, wholeWord,
searchConfigurations.searchEmbedMode, line, matches);
}
} else {
throw StateError('Unreachable.');
@ -295,6 +301,7 @@ class Document {
String substring,
bool caseSensitive,
bool wholeWord,
SearchEmbedMode searchEmbedMode,
Line line,
List<int> matches,
) {
@ -312,6 +319,47 @@ class Document {
}
matches.add(index + line.documentOffset);
}
//
if (line.hasEmbed && searchEmbedMode != SearchEmbedMode.none) {
Node? node = line.children.first;
while (node != null) {
if (node is Embed) {
final ofs = node.offset;
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);
if (index < 0) {
matches.add(documentOffset);
} else {
matches.insert(index, documentOffset);
}
}
}
node = node.next;
}
}
}
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

@ -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,6 +58,7 @@ class QuillEditorConfigurations extends Equatable {
this.enableMarkdownStyleConversion = true,
this.embedBuilders,
this.unknownEmbedBuilder,
this.searchConfigurations = const QuillSearchConfigurations(),
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder,
this.customRecognizerBuilder,
@ -281,6 +283,8 @@ class QuillEditorConfigurations extends Equatable {
final CustomStyleBuilder? customStyleBuilder;
final CustomRecognizerBuilder? customRecognizerBuilder;
final QuillSearchConfigurations searchConfigurations;
/// Delegate function responsible for showing menu with link actions on
/// mobile platforms (iOS, Android).
///
@ -422,6 +426,7 @@ class QuillEditorConfigurations extends Equatable {
ValueChanged<String>? onLaunchUrl,
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
QuillSearchConfigurations? searchConfigurations,
CustomStyleBuilder? customStyleBuilder,
CustomRecognizerBuilder? customRecognizerBuilder,
LinkActionPickerDelegate? linkActionPickerDelegate,
@ -481,6 +486,7 @@ class QuillEditorConfigurations extends Equatable {
onLaunchUrl: onLaunchUrl ?? this.onLaunchUrl,
embedBuilders: embedBuilders ?? this.embedBuilders,
unknownEmbedBuilder: unknownEmbedBuilder ?? this.unknownEmbedBuilder,
searchConfigurations: searchConfigurations ?? this.searchConfigurations,
customStyleBuilder: customStyleBuilder ?? this.customStyleBuilder,
customRecognizerBuilder:
customRecognizerBuilder ?? this.customRecognizerBuilder,

@ -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,
);
}
}

@ -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';

@ -258,38 +258,60 @@ class QuillToolbarSearchDialogState extends State<QuillToolbarSearchDialog> {
}
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;
}
setState(() {
final currPos = _offsets.isNotEmpty ? _offsets[_index] : 0;
_offsets = widget.controller.document.search(
_text,
caseSensitive: _caseSensitive,
wholeWord: _wholeWord,
);
_index = 0;
if (_offsets.isEmpty) {
clearSelection();
} else {
// Select the next hit position
for (var n = 0; n < _offsets.length; n++) {
if (_offsets[n] >= currPos) {
_index = n;
break;
}
}
_moveToPosition();
}
});
if (_offsets.isNotEmpty) {
_moveToPosition();
}
}
void _moveToPosition() {
final offset = _offsets[_index];
var len = _text.length;
/// Trap search hit within embed must only show selection of the embed
final leaf = widget.controller.queryNode(offset);
if (leaf is Embed) {
len = 1;
}
widget.controller.updateSelection(
TextSelection(
baseOffset: _offsets[_index],
extentOffset: _offsets[_index] + _text.length,
baseOffset: offset,
extentOffset: offset + len,
),
ChangeSource.local,
);

@ -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]);
});
});
}

@ -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<String, dynamic> start, Attribute attr,
Map<String, dynamic> change) {
void doTest(Map<String, dynamic> 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.

Loading…
Cancel
Save