Skip to content

Commit

Permalink
Enhancement: Search within Embed objects (#2090)
Browse files Browse the repository at this point in the history
  • Loading branch information
AtlasAutocode authored Aug 4, 2024
1 parent 80ee3f4 commit 40e18b2
Show file tree
Hide file tree
Showing 11 changed files with 388 additions and 55 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions doc/configurations/search.md
Original file line number Diff line number Diff line change
@@ -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;
}
...
```
3 changes: 3 additions & 0 deletions example/lib/screens/quill/quill_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions lib/src/controller/quill_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
100 changes: 74 additions & 26 deletions lib/src/document/document.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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].
Expand All @@ -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.');
Expand All @@ -295,6 +301,7 @@ class Document {
String substring,
bool caseSensitive,
bool wholeWord,
SearchEmbedMode searchEmbedMode,
Line line,
List<int> matches,
) {
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/src/editor/config/editor_configurations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
///
Expand Down Expand Up @@ -422,6 +426,7 @@ class QuillEditorConfigurations extends Equatable {
ValueChanged<String>? onLaunchUrl,
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
QuillSearchConfigurations? searchConfigurations,
CustomStyleBuilder? customStyleBuilder,
CustomRecognizerBuilder? customRecognizerBuilder,
LinkActionPickerDelegate? linkActionPickerDelegate,
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions lib/src/editor/config/search_configurations.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 40e18b2

Please sign in to comment.