Skip to content

Commit

Permalink
feat: support selection counters (#706)
Browse files Browse the repository at this point in the history
* feat: support selection counters

* feat: improve
  • Loading branch information
Xazin authored Feb 8, 2024
1 parent 9c27b8e commit 63844fd
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 40 deletions.
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:flutter/widgets.dart';

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

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

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 @@ class WordCountService with ChangeNotifier {
}

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

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

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

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

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

_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();

return notifyListeners();
}

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 @@ class WordCountService with ChangeNotifier {
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) {
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 @@ class WordCountService with ChangeNotifier {
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

0 comments on commit 63844fd

Please sign in to comment.