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: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<HomePage> {
|
|||
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<HomePage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
return _widgetBuilder(context);
|
||||
}
|
||||
|
||||
Widget _buildListTile(
|
||||
BuildContext context,
|
||||
String text,
|
||||
|
|
|
|||
|
|
@ -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<Editor> {
|
||||
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<String>(
|
||||
future: widget.jsonString,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.connectionState == ConnectionState.done) {
|
||||
EditorState editorState = EditorState(
|
||||
document: Document.fromJson(
|
||||
Map<String, Object>.from(
|
||||
json.decode(snapshot.data!),
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.logConfiguration
|
||||
..handler = debugPrint
|
||||
..level = LogLevel.off;
|
||||
return Stack(
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: Colors.white,
|
||||
child: FutureBuilder<String>(
|
||||
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<String, Object>.from(
|
||||
json.decode(snapshot.data!),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
editorState.transactionStream.listen((event) {
|
||||
if (event.$1 == TransactionTime.after) {
|
||||
widget.onEditorStateChange(editorState);
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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