diff --git a/example/lib/pages/editor.dart b/example/lib/pages/editor.dart index 3b42c131e..a8edbd999 100644 --- a/example/lib/pages/editor.dart +++ b/example/lib/pages/editor.dart @@ -44,6 +44,9 @@ class _EditorState extends State { int wordCount = 0; int charCount = 0; + int selectedWordCount = 0; + int selectedCharCount = 0; + void registerWordCounter() { wordCountService?.removeListener(onWordCountUpdate); wordCountService?.dispose(); @@ -58,8 +61,10 @@ class _EditorState extends State { 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; }); } @@ -132,9 +137,18 @@ class _EditorState extends State { : 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), + ), + ], ), ), ), diff --git a/lib/src/plugins/word_count/word_counter_service.dart b/lib/src/plugins/word_count/word_counter_service.dart index b5cea5de3..da31e5815 100644 --- a/lib/src/plugins/word_count/word_counter_service.dart +++ b/lib/src/plugins/word_count/word_counter_service.dart @@ -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]. /// @@ -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 @@ -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. @@ -72,8 +102,8 @@ class WordCountService with ChangeNotifier { } _streamSubscription?.cancel(); - _wordCount = 0; - _charCount = 0; + _documentCounters = const Counters(); + _selectionCounters = const Counters(); isRunning = false; notifyListeners(); @@ -81,10 +111,44 @@ class WordCountService with ChangeNotifier { @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, ) { @@ -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; @@ -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; diff --git a/test/plugins/word_count/word_counter_test.dart b/test/plugins/word_count/word_counter_test.dart index ba7080765..b7bbe44ec 100644 --- a/test/plugins/word_count/word_counter_test.dart +++ b/test/plugins/word_count/word_counter_test.dart @@ -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(); }); @@ -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); @@ -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);