Skip to content

Commit

Permalink
feat: word counter service (#705)
Browse files Browse the repository at this point in the history
* feat: word counter service

* chore: minor improvement

* test: add expect for stop()

* fix: code review
  • Loading branch information
Xazin authored Feb 7, 2024
1 parent 7000a19 commit 9c27b8e
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 59 deletions.
18 changes: 7 additions & 11 deletions example/lib/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
);
}

Expand Down Expand Up @@ -220,10 +220,6 @@ class _HomePageState extends State<HomePage> {
);
}

Widget _buildBody(BuildContext context) {
return _widgetBuilder(context);
}

Widget _buildListTile(
BuildContext context,
String text,
Expand Down
148 changes: 105 additions & 43 deletions example/lib/pages/editor.dart
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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;

editorState.transactionStream.listen((event) {
if (event.$1 == TransactionTime.after) {
widget.onEditorStateChange(editorState);
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.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),
),
),
),
],
);
}
}
9 changes: 4 additions & 5 deletions lib/src/editor_state.dart
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -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].
Expand Down
1 change: 1 addition & 0 deletions lib/src/plugins/plugins.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 126 additions & 0 deletions lib/src/plugins/word_count/word_counter_service.dart
Original file line number Diff line number Diff line change
@@ -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() ?? '';
}
Loading

0 comments on commit 9c27b8e

Please sign in to comment.