feat: word counter service (#705)

* feat: word counter service

* chore: minor improvement

* test: add expect for stop()

* fix: code review
This commit is contained in:
Mathias Mogensen 2024-02-07 16:30:27 +01:00 • committed by GitHub
commit 9c27b8e309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 310 additions and 59 deletions

View file

@ -3,18 +3,20 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; 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/customize_theme_for_editor.dart';
import 'package:example/pages/editor.dart'; import 'package:example/pages/editor.dart';
import 'package:example/pages/editor_list.dart'; import 'package:example/pages/editor_list.dart';
import 'package:example/pages/focus_example_for_editor.dart'; import 'package:example/pages/focus_example_for_editor.dart';
import 'package:file_picker/file_picker.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:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html; import 'package:universal_html/html.dart' as html;
import 'package:appflowy_editor/appflowy_editor.dart';
enum ExportFileType { enum ExportFileType {
documentJson, documentJson,
markdown, markdown,
@ -93,9 +95,7 @@ class _HomePageState extends State<HomePage> {
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: const Text('AppFlowy Editor'), title: const Text('AppFlowy Editor'),
), ),
body: SafeArea( body: SafeArea(child: _widgetBuilder(context)),
child: _buildBody(context),
),
); );
} }
@ -220,10 +220,6 @@ class _HomePageState extends State<HomePage> {
); );
} }
Widget _buildBody(BuildContext context) {
return _widgetBuilder(context);
}
Widget _buildListTile( Widget _buildListTile(
BuildContext context, BuildContext context,
String text, String text,

View file

@ -1,9 +1,11 @@
import 'dart:convert'; 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/desktop_editor.dart';
import 'package:example/pages/mobile_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 { class Editor extends StatefulWidget {
const Editor({ const Editor({
@ -25,58 +27,118 @@ class Editor extends StatefulWidget {
} }
class _EditorState extends State<Editor> { class _EditorState extends State<Editor> {
bool isInitialized = false;
EditorState? editorState; 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 @override
void dispose() { void dispose() {
editorState?.dispose(); editorState?.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Stack(
color: Colors.white, children: [
child: FutureBuilder<String>( ColoredBox(
future: widget.jsonString, color: Colors.white,
builder: (context, snapshot) { child: FutureBuilder<String>(
if (snapshot.hasData && future: widget.jsonString,
snapshot.connectionState == ConnectionState.done) { builder: (context, snapshot) {
EditorState editorState = EditorState( if (snapshot.hasData &&
document: Document.fromJson( snapshot.connectionState == ConnectionState.done) {
Map<String, Object>.from( if (!isInitialized || editorState == null) {
json.decode(snapshot.data!), isInitialized = true;
), EditorState editorState = EditorState(
), document: Document.fromJson(
); Map<String, Object>.from(
editorState.logConfiguration json.decode(snapshot.data!),
..handler = debugPrint ),
..level = LogLevel.off; ),
);
editorState.transactionStream.listen((event) { editorState.logConfiguration
if (event.$1 == TransactionTime.after) { ..handler = debugPrint
widget.onEditorStateChange(editorState); ..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; return const SizedBox.shrink();
},
if (PlatformExtension.isDesktopOrWeb) { ),
return DesktopEditor( ),
editorState: editorState, Positioned(
textDirection: widget.textDirection, bottom: 0,
); right: 0,
} else if (PlatformExtension.isMobile) { child: Container(
return MobileEditor( padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 12),
editorState: editorState, decoration: BoxDecoration(
); color: Colors.black.withOpacity(0.1),
} borderRadius: BorderRadius.only(
} topLeft: const Radius.circular(8),
bottomLeft: PlatformExtension.isMobile
return const SizedBox.shrink(); ? const Radius.circular(8)
}, : Radius.zero,
), ),
),
child: Text(
'Word Count: $wordCount | Character Count: $charCount',
style: const TextStyle(fontSize: 11),
),
),
),
],
); );
} }
} }

View file

@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.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/editor/editor_component/service/scroll/auto_scroller.dart';
import 'package:appflowy_editor/src/history/undo_manager.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. /// the type of this value is bool.
/// ///
@ -150,9 +151,7 @@ class EditorState {
Stream<(TransactionTime, Transaction)> get transactionStream => Stream<(TransactionTime, Transaction)> get transactionStream =>
_observer.stream; _observer.stream;
final StreamController<(TransactionTime, Transaction)> _observer = final StreamController<(TransactionTime, Transaction)> _observer =
StreamController.broadcast( StreamController.broadcast(sync: true);
sync: true,
);
/// Store the toggled format style, like bold, italic, etc. /// Store the toggled format style, like bold, italic, etc.
/// All the values must be the key from [AppFlowyRichTextKeys.supportToggled]. /// All the values must be the key from [AppFlowyRichTextKeys.supportToggled].

View file

@ -9,3 +9,4 @@ export 'markdown/encoder/delta_markdown_encoder.dart';
export 'markdown/encoder/document_markdown_encoder.dart'; export 'markdown/encoder/document_markdown_encoder.dart';
export 'markdown/encoder/parser/parser.dart'; export 'markdown/encoder/parser/parser.dart';
export 'quill_delta/quill_delta_encoder.dart'; export 'quill_delta/quill_delta_encoder.dart';
export 'word_count/word_counter_service.dart';

View file

@ -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() ?? '';
}

View file

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