Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support selection counters #706

Merged
merged 2 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions example/lib/pages/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ class _EditorState extends State<Editor> {
int wordCount = 0;
int charCount = 0;

int selectedWordCount = 0;
int selectedCharCount = 0;

void registerWordCounter() {
wordCountService?.removeListener(onWordCountUpdate);
wordCountService?.dispose();
Expand All @@ -58,8 +61,10 @@ class _EditorState extends State<Editor> {

void onWordCountUpdate() {
setState(() {
wordCount = wordCountService!.wordCount;
charCount = wordCountService!.charCount;
wordCount = wordCountService!.documentCounters.wordCount;
charCount = wordCountService!.documentCounters.charCount;
selectedWordCount = wordCountService!.selectionCounters.wordCount;
selectedCharCount = wordCountService!.selectionCounters.charCount;
});
}

Expand Down Expand Up @@ -132,9 +137,18 @@ class _EditorState extends State<Editor> {
: Radius.zero,
),
),
child: Text(
'Word Count: $wordCount | Character Count: $charCount',
style: const TextStyle(fontSize: 11),
child: Column(
children: [
Text(
'Word Count: $wordCount | Character Count: $charCount',
style: const TextStyle(fontSize: 11),
),
if (!(editorState?.selection?.isCollapsed ?? true))
Text(
'(In-selection) Word Count: $selectedWordCount | Character Count: $selectedCharCount',
style: const TextStyle(fontSize: 11),
),
],
),
),
),
Expand Down
115 changes: 90 additions & 25 deletions lib/src/plugins/word_count/word_counter_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,36 @@

import 'package:appflowy_editor/appflowy_editor.dart';

const _emptyCounters = Counters();
final _wordRegex = RegExp(r"\w+(\'\w+)?");

/// Used by the [WordCountService] to contain
/// count statistics in eg. a [Document] or in
/// the current [Selection].
///
class Counters {
const Counters({
int wordCount = 0,
int charCount = 0,
}) : _wordCount = wordCount,
_charCount = charCount;

final int _wordCount;
int get wordCount => _wordCount;

final int _charCount;
int get charCount => _charCount;

@override
bool operator ==(other) =>
other is Counters &&
other.wordCount == wordCount &&
other.charCount == charCount;

@override
int get hashCode => Object.hash(wordCount, charCount);

Check warning on line 34 in lib/src/plugins/word_count/word_counter_service.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/plugins/word_count/word_counter_service.dart#L33-L34

Added lines #L33 - L34 were not covered by tests
}

/// A Word Counter service that runs based on the
/// changes and updates to an [EditorState].
///
Expand All @@ -24,17 +52,15 @@

final EditorState editorState;

int _wordCount = 0;

/// Number of words in the [Document].
/// Number of words and characters in the [Document].
///
int get wordCount => _wordCount;

int _charCount = 0;
Counters get documentCounters => _documentCounters;
Counters _documentCounters = const Counters();

/// Number of characters with spaces in the [Document].
/// Number of words and characters in the [Selection].
///
int get charCount => _charCount;
Counters get selectionCounters => _selectionCounters;
Counters _selectionCounters = const Counters();

/// Signifies whether the service is currently running
/// or not. The service can be stopped/started as needed
Expand All @@ -53,15 +79,19 @@
}

isRunning = true;
_documentCounters = _countersFromNode(editorState.document.root);
if (editorState.selection?.isCollapsed ?? false) {
_recountOnSelectionUpdate();

Check warning on line 84 in lib/src/plugins/word_count/word_counter_service.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/plugins/word_count/word_counter_service.dart#L84

Added line #L84 was not covered by tests
}

final counters = _countersFromNode(editorState.document.root);
_wordCount = counters.$1;
_charCount = counters.$2;

notifyListeners();
if (documentCounters != _emptyCounters ||
selectionCounters != _emptyCounters) {

Check warning on line 88 in lib/src/plugins/word_count/word_counter_service.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/plugins/word_count/word_counter_service.dart#L88

Added line #L88 was not covered by tests
notifyListeners();
}

_streamSubscription =
editorState.transactionStream.listen(_recountOnTransactionUpdate);
editorState.selectionNotifier.addListener(_recountOnSelectionUpdate);
}

/// Stops the Word Counter and resets the counts.
Expand All @@ -72,19 +102,53 @@
}

_streamSubscription?.cancel();
_wordCount = 0;
_charCount = 0;
_documentCounters = const Counters();
_selectionCounters = const Counters();
isRunning = false;

notifyListeners();
}

@override
void dispose() {
editorState.selectionNotifier.removeListener(_recountOnSelectionUpdate);
_streamSubscription?.cancel();
super.dispose();
}

void _recountOnSelectionUpdate() {
// If collapsed or null, reset count
if (editorState.selection?.isCollapsed ?? true) {
if (_selectionCounters == _emptyCounters) {
return;
}

_selectionCounters = const Counters();

Check warning on line 126 in lib/src/plugins/word_count/word_counter_service.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/plugins/word_count/word_counter_service.dart#L126

Added line #L126 was not covered by tests

return notifyListeners();

Check warning on line 128 in lib/src/plugins/word_count/word_counter_service.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/plugins/word_count/word_counter_service.dart#L128

Added line #L128 was not covered by tests
}

int wordCount = 0;
int charCount = 0;

final nodes = editorState.getSelectedNodes();
for (final node in nodes) {
final counters = _countersFromNode(node);
wordCount += counters.wordCount;
charCount += counters.charCount;
}

final newCounters = Counters(
wordCount: wordCount,
charCount: charCount,
);

if (newCounters != selectionCounters) {
_selectionCounters = newCounters;
notifyListeners();
}
}

void _recountOnTransactionUpdate(
(TransactionTime time, Transaction t) event,
) {
Expand All @@ -95,15 +159,16 @@
final counters = _countersFromNode(editorState.document.root);

// If there is no update, no need to notify listeners
if (counters.$1 != wordCount || counters.$2 != charCount) {
_wordCount = counters.$1;
_charCount = counters.$2;

notifyListeners();
if (counters.wordCount != documentCounters.wordCount ||
counters.charCount != documentCounters.charCount) {

Check warning on line 163 in lib/src/plugins/word_count/word_counter_service.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/plugins/word_count/word_counter_service.dart#L163

Added line #L163 was not covered by tests
if (counters != documentCounters) {
_documentCounters = counters;
notifyListeners();
}
}
}

(int, int) _countersFromNode(Node node) {
Counters _countersFromNode(Node node) {
int wCount = 0;
int cCount = 0;

Expand All @@ -112,12 +177,12 @@
cCount += plain.runes.length;

for (final child in node.children) {
final values = _countersFromNode(child);
wCount += values.$1;
cCount += values.$2;
final counters = _countersFromNode(child);
wCount += counters.wordCount;
cCount += counters.charCount;
}

return (wCount, cCount);
return Counters(wordCount: wCount, charCount: cCount);
}

int _wordsInString(String delta) => _wordRegex.allMatches(delta).length;
Expand Down
66 changes: 56 additions & 10 deletions test/plugins/word_count/word_counter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import 'package:flutter_test/flutter_test.dart';
import '../../new/infra/testable_editor.dart';

void main() async {
late final WordCountService service;
late WordCountService service;

setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});

tearDown(() {
tearDownAll(() {
service.dispose();
});

Expand All @@ -24,15 +24,15 @@ void main() async {

service = WordCountService(editorState: editor.editorState)..register();

expect(service.wordCount, 3 * 3); // 9 Words
expect(service.charCount, text.length * 3);
expect(service.documentCounters.wordCount, 3 * 3); // 9 Words
expect(service.documentCounters.charCount, text.length * 3);

int wordCount = 0;
int charCount = 0;

void setCounters() {
wordCount = service.wordCount;
charCount = service.charCount;
wordCount = service.documentCounters.wordCount;
charCount = service.documentCounters.charCount;
}

service.addListener(setCounters);
Expand All @@ -48,15 +48,61 @@ void main() async {

await tester.pumpAndSettle();

expect(service.wordCount, 3 * 4);
expect(service.charCount, text.length * 4);
expect(service.documentCounters.wordCount, 3 * 4);
expect(service.documentCounters.charCount, text.length * 4);
expect(wordCount, 3 * 4);
expect(charCount, text.length * 4);

service.stop();

expect(service.wordCount, 0);
expect(service.charCount, 0);
expect(service.documentCounters.wordCount, 0);
expect(service.documentCounters.charCount, 0);
expect(wordCount, 0);
expect(charCount, 0);

service.removeListener(setCounters);
},
);

testWidgets(
'Selection Word and Character count updates',
(tester) async {
const text = 'Welcome to Appflowy!';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();

service = WordCountService(editorState: editor.editorState)..register();

expect(service.selectionCounters.wordCount, 0);
expect(service.selectionCounters.charCount, 0);

int wordCount = 0;
int charCount = 0;

void setCounters() {
wordCount = service.selectionCounters.wordCount;
charCount = service.selectionCounters.charCount;
}

service.addListener(setCounters);

await editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: text.length),
),
);
await tester.pumpAndSettle();

expect(service.selectionCounters.wordCount, 3);
expect(service.selectionCounters.charCount, text.length);
expect(wordCount, 3);
expect(charCount, text.length);

service.stop();

expect(service.selectionCounters.wordCount, 0);
expect(service.selectionCounters.charCount, 0);
expect(wordCount, 0);
expect(charCount, 0);

Expand Down
Loading