From c80d67ba9c884c95db9b55bb0083ca2ac72c9976 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Wed, 7 Feb 2024 21:04:48 +0100 Subject: [PATCH 1/2] feat: support selection counters --- example/lib/pages/editor.dart | 24 ++++-- .../word_count/word_counter_service.dart | 80 +++++++++++++++---- .../plugins/word_count/word_counter_test.dart | 66 ++++++++++++--- 3 files changed, 140 insertions(+), 30 deletions(-) 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..05ac4e0df 100644 --- a/lib/src/plugins/word_count/word_counter_service.dart +++ b/lib/src/plugins/word_count/word_counter_service.dart @@ -6,6 +6,24 @@ import 'package:appflowy_editor/appflowy_editor.dart'; 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; +} + /// A Word Counter service that runs based on the /// changes and updates to anĀ [EditorState]. /// @@ -24,17 +42,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 @@ -55,13 +71,20 @@ class WordCountService with ChangeNotifier { isRunning = true; final counters = _countersFromNode(editorState.document.root); - _wordCount = counters.$1; - _charCount = counters.$2; + _documentCounters = Counters( + wordCount: counters.$1, + charCount: counters.$2, + ); + + if (editorState.selection?.isCollapsed ?? false) { + _recountOnSelectionUpdate(); + } notifyListeners(); _streamSubscription = editorState.transactionStream.listen(_recountOnTransactionUpdate); + editorState.selectionNotifier.addListener(_recountOnSelectionUpdate); } /// Stops the Word Counter and resets the counts. @@ -72,8 +95,8 @@ class WordCountService with ChangeNotifier { } _streamSubscription?.cancel(); - _wordCount = 0; - _charCount = 0; + _documentCounters = const Counters(); + _selectionCounters = const Counters(); isRunning = false; notifyListeners(); @@ -81,10 +104,34 @@ 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) { + _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.$1; + charCount += counters.$2; + } + + _selectionCounters = Counters(wordCount: wordCount, charCount: charCount); + + notifyListeners(); + } + void _recountOnTransactionUpdate( (TransactionTime time, Transaction t) event, ) { @@ -95,9 +142,12 @@ 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; + if (counters.$1 != documentCounters.wordCount || + counters.$2 != documentCounters.charCount) { + _documentCounters = Counters( + wordCount: counters.$1, + charCount: counters.$2, + ); notifyListeners(); } 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); From 87fe27114d76389826eb20ae9605a8d6d28adfdc Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Wed, 7 Feb 2024 21:57:50 +0100 Subject: [PATCH 2/2] feat: improve --- .../word_count/word_counter_service.dart | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/lib/src/plugins/word_count/word_counter_service.dart b/lib/src/plugins/word_count/word_counter_service.dart index 05ac4e0df..da31e5815 100644 --- a/lib/src/plugins/word_count/word_counter_service.dart +++ b/lib/src/plugins/word_count/word_counter_service.dart @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,15 @@ class Counters { 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 @@ -69,18 +79,15 @@ class WordCountService with ChangeNotifier { } isRunning = true; - - final counters = _countersFromNode(editorState.document.root); - _documentCounters = Counters( - wordCount: counters.$1, - charCount: counters.$2, - ); - + _documentCounters = _countersFromNode(editorState.document.root); if (editorState.selection?.isCollapsed ?? false) { _recountOnSelectionUpdate(); } - notifyListeners(); + if (documentCounters != _emptyCounters || + selectionCounters != _emptyCounters) { + notifyListeners(); + } _streamSubscription = editorState.transactionStream.listen(_recountOnTransactionUpdate); @@ -112,6 +119,10 @@ class WordCountService with ChangeNotifier { void _recountOnSelectionUpdate() { // If collapsed or null, reset count if (editorState.selection?.isCollapsed ?? true) { + if (_selectionCounters == _emptyCounters) { + return; + } + _selectionCounters = const Counters(); return notifyListeners(); @@ -123,13 +134,19 @@ class WordCountService with ChangeNotifier { final nodes = editorState.getSelectedNodes(); for (final node in nodes) { final counters = _countersFromNode(node); - wordCount += counters.$1; - charCount += counters.$2; + wordCount += counters.wordCount; + charCount += counters.charCount; } - _selectionCounters = Counters(wordCount: wordCount, charCount: charCount); + final newCounters = Counters( + wordCount: wordCount, + charCount: charCount, + ); - notifyListeners(); + if (newCounters != selectionCounters) { + _selectionCounters = newCounters; + notifyListeners(); + } } void _recountOnTransactionUpdate( @@ -142,18 +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 != documentCounters.wordCount || - counters.$2 != documentCounters.charCount) { - _documentCounters = Counters( - 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; @@ -162,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;