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:
parent
7000a19d81
commit
9c27b8e309
6 changed files with 310 additions and 59 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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].
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
126
lib/src/plugins/word_count/word_counter_service.dart
Normal file
126
lib/src/plugins/word_count/word_counter_service.dart
Normal 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() ?? '';
|
||||||
|
}
|
||||||
67
test/plugins/word_count/word_counter_test.dart
Normal file
67
test/plugins/word_count/word_counter_test.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue