diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index 2c2c79774..b4c81ee61 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -3,18 +3,20 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:example/pages/customize_theme_for_editor.dart'; import 'package:example/pages/editor.dart'; import 'package:example/pages/editor_list.dart'; import 'package:example/pages/focus_example_for_editor.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:universal_html/html.dart' as html; +import 'package:appflowy_editor/appflowy_editor.dart'; + enum ExportFileType { documentJson, markdown, @@ -93,9 +95,7 @@ class _HomePageState extends State { surfaceTintColor: Colors.transparent, title: const Text('AppFlowy Editor'), ), - body: SafeArea( - child: _buildBody(context), - ), + body: SafeArea(child: _widgetBuilder(context)), ); } @@ -220,10 +220,6 @@ class _HomePageState extends State { ); } - Widget _buildBody(BuildContext context) { - return _widgetBuilder(context); - } - Widget _buildListTile( BuildContext context, String text, diff --git a/example/lib/pages/editor.dart b/example/lib/pages/editor.dart index 68e0cbb34..3b42c131e 100644 --- a/example/lib/pages/editor.dart +++ b/example/lib/pages/editor.dart @@ -1,9 +1,11 @@ import 'dart:convert'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + import 'package:example/pages/desktop_editor.dart'; import 'package:example/pages/mobile_editor.dart'; -import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; class Editor extends StatefulWidget { const Editor({ @@ -25,58 +27,118 @@ class Editor extends StatefulWidget { } class _EditorState extends State { + bool isInitialized = false; + EditorState? editorState; + WordCountService? wordCountService; + + @override + void didUpdateWidget(covariant Editor oldWidget) { + if (oldWidget.jsonString != widget.jsonString) { + editorState = null; + isInitialized = false; + } + super.didUpdateWidget(oldWidget); + } + + int wordCount = 0; + int charCount = 0; + + void registerWordCounter() { + wordCountService?.removeListener(onWordCountUpdate); + wordCountService?.dispose(); + + wordCountService = WordCountService(editorState: editorState!)..register(); + wordCountService!.addListener(onWordCountUpdate); + + WidgetsBinding.instance.addPostFrameCallback((_) { + onWordCountUpdate(); + }); + } + + void onWordCountUpdate() { + setState(() { + wordCount = wordCountService!.wordCount; + charCount = wordCountService!.charCount; + }); + } @override void dispose() { editorState?.dispose(); - super.dispose(); } @override Widget build(BuildContext context) { - return Container( - color: Colors.white, - child: FutureBuilder( - future: widget.jsonString, - builder: (context, snapshot) { - if (snapshot.hasData && - snapshot.connectionState == ConnectionState.done) { - EditorState editorState = EditorState( - document: Document.fromJson( - Map.from( - json.decode(snapshot.data!), - ), - ), - ); - editorState.logConfiguration - ..handler = debugPrint - ..level = LogLevel.off; - - editorState.transactionStream.listen((event) { - if (event.$1 == TransactionTime.after) { - widget.onEditorStateChange(editorState); + return Stack( + children: [ + ColoredBox( + color: Colors.white, + child: FutureBuilder( + future: widget.jsonString, + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + if (!isInitialized || editorState == null) { + isInitialized = true; + EditorState editorState = EditorState( + document: Document.fromJson( + Map.from( + json.decode(snapshot.data!), + ), + ), + ); + + editorState.logConfiguration + ..handler = debugPrint + ..level = LogLevel.off; + + editorState.transactionStream.listen((event) { + if (event.$1 == TransactionTime.after) { + widget.onEditorStateChange(editorState); + } + }); + + this.editorState = editorState; + registerWordCounter(); + } + + if (PlatformExtension.isDesktopOrWeb) { + return DesktopEditor( + editorState: editorState!, + textDirection: widget.textDirection, + ); + } else if (PlatformExtension.isMobile) { + return MobileEditor(editorState: editorState!); + } } - }); - - this.editorState = editorState; - - if (PlatformExtension.isDesktopOrWeb) { - return DesktopEditor( - editorState: editorState, - textDirection: widget.textDirection, - ); - } else if (PlatformExtension.isMobile) { - return MobileEditor( - editorState: editorState, - ); - } - } - - return const SizedBox.shrink(); - }, - ), + + return const SizedBox.shrink(); + }, + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.1), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(8), + bottomLeft: PlatformExtension.isMobile + ? const Radius.circular(8) + : Radius.zero, + ), + ), + child: Text( + 'Word Count: $wordCount | Character Count: $charCount', + style: const TextStyle(fontSize: 11), + ), + ), + ), + ], ); } } diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 963560f21..93073c39e 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -1,10 +1,11 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/scroll/auto_scroller.dart'; import 'package:appflowy_editor/src/history/undo_manager.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; /// the type of this value is bool. /// @@ -150,9 +151,7 @@ class EditorState { Stream<(TransactionTime, Transaction)> get transactionStream => _observer.stream; final StreamController<(TransactionTime, Transaction)> _observer = - StreamController.broadcast( - sync: true, - ); + StreamController.broadcast(sync: true); /// Store the toggled format style, like bold, italic, etc. /// All the values must be the key from [AppFlowyRichTextKeys.supportToggled]. diff --git a/lib/src/plugins/plugins.dart b/lib/src/plugins/plugins.dart index 1bf0ebc80..b512bf064 100644 --- a/lib/src/plugins/plugins.dart +++ b/lib/src/plugins/plugins.dart @@ -9,3 +9,4 @@ export 'markdown/encoder/delta_markdown_encoder.dart'; export 'markdown/encoder/document_markdown_encoder.dart'; export 'markdown/encoder/parser/parser.dart'; export 'quill_delta/quill_delta_encoder.dart'; +export 'word_count/word_counter_service.dart'; diff --git a/lib/src/plugins/word_count/word_counter_service.dart b/lib/src/plugins/word_count/word_counter_service.dart new file mode 100644 index 000000000..b5cea5de3 --- /dev/null +++ b/lib/src/plugins/word_count/word_counter_service.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + +final _wordRegex = RegExp(r"\w+(\'\w+)?"); + +/// A Word Counter service that runs based on the +/// changes and updates to an [EditorState]. +/// +/// Due to this service relying on listening to transactions +/// in the [Document] and iterating the complete [Document] +/// to count the words and characters, this can be a potential +/// slow and cumbersome task. +/// +/// To start being notified about updates, run the [register] +/// method, this will add a listener to the [Transaction] updates +/// of the [EditorState], and do an initial run-through to populate +/// the counter stats. +/// +class WordCountService with ChangeNotifier { + WordCountService({required this.editorState}); + + final EditorState editorState; + + int _wordCount = 0; + + /// Number of words in the [Document]. + /// + int get wordCount => _wordCount; + + int _charCount = 0; + + /// Number of characters with spaces in the [Document]. + /// + int get charCount => _charCount; + + /// Signifies whether the service is currently running + /// or not. The service can be stopped/started as needed + /// for performance. + /// + bool isRunning = false; + + StreamSubscription<(TransactionTime, Transaction)>? _streamSubscription; + + /// Registers the Word Counter and starts notifying + /// about updates to word and character count. + /// + void register() { + if (isRunning) { + return; + } + + isRunning = true; + + final counters = _countersFromNode(editorState.document.root); + _wordCount = counters.$1; + _charCount = counters.$2; + + notifyListeners(); + + _streamSubscription = + editorState.transactionStream.listen(_recountOnTransactionUpdate); + } + + /// Stops the Word Counter and resets the counts. + /// + void stop() { + if (!isRunning) { + return; + } + + _streamSubscription?.cancel(); + _wordCount = 0; + _charCount = 0; + isRunning = false; + + notifyListeners(); + } + + @override + void dispose() { + _streamSubscription?.cancel(); + super.dispose(); + } + + void _recountOnTransactionUpdate( + (TransactionTime time, Transaction t) event, + ) { + if (event.$1 != TransactionTime.after) { + return; + } + + 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(); + } + } + + (int, int) _countersFromNode(Node node) { + int wCount = 0; + int cCount = 0; + + final plain = _toPlainText(node); + wCount += _wordsInString(plain); + cCount += plain.runes.length; + + for (final child in node.children) { + final values = _countersFromNode(child); + wCount += values.$1; + cCount += values.$2; + } + + return (wCount, cCount); + } + + int _wordsInString(String delta) => _wordRegex.allMatches(delta).length; + + String _toPlainText(Node node) => node.delta?.toPlainText() ?? ''; +} diff --git a/test/plugins/word_count/word_counter_test.dart b/test/plugins/word_count/word_counter_test.dart new file mode 100644 index 000000000..ba7080765 --- /dev/null +++ b/test/plugins/word_count/word_counter_test.dart @@ -0,0 +1,67 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../new/infra/testable_editor.dart'; + +void main() async { + late final WordCountService service; + + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + tearDown(() { + service.dispose(); + }); + + group('word_counter_service.dart', () { + testWidgets( + '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.wordCount, 3 * 3); // 9 Words + expect(service.charCount, text.length * 3); + + int wordCount = 0; + int charCount = 0; + + void setCounters() { + wordCount = service.wordCount; + charCount = service.charCount; + } + + service.addListener(setCounters); + + final transaction = editor.editorState.transaction + ..insertText( + editor.editorState.getNodeAtPath([0])!, + text.length, + text, + ); + + await editor.editorState.apply(transaction); + + await tester.pumpAndSettle(); + + expect(service.wordCount, 3 * 4); + expect(service.charCount, text.length * 4); + expect(wordCount, 3 * 4); + expect(charCount, text.length * 4); + + service.stop(); + + expect(service.wordCount, 0); + expect(service.charCount, 0); + expect(wordCount, 0); + expect(charCount, 0); + + service.removeListener(setCounters); + }, + ); + }); +}