Skip to main content

Overview

The Word Count service provides real-time tracking of word and character counts in AppFlowy Editor documents. It monitors document changes and selection updates to provide accurate statistics.

Installation

The Word Count service is included with AppFlowy Editor:
import 'package:appflowy_editor/appflowy_editor.dart';

Basic Usage

Creating the Service

final wordCountService = WordCountService(
  editorState: editorState,
  debounceDuration: const Duration(milliseconds: 300),
);

Starting the Service

Register the service to start receiving updates:
wordCountService.register();

Listening to Changes

The service uses ChangeNotifier to broadcast updates:
wordCountService.addListener(() {
  print('Document: ${wordCountService.documentCounters.wordCount} words');
  print('Selection: ${wordCountService.selectionCounters.wordCount} words');
});

Stopping the Service

wordCountService.stop();
wordCountService.dispose();

Features

Document Statistics

Track statistics for the entire document:
final counters = wordCountService.documentCounters;
print('Words: ${counters.wordCount}');
print('Characters: ${counters.charCount}');

Selection Statistics

Track statistics for the current selection:
final counters = wordCountService.selectionCounters;
if (counters.wordCount > 0) {
  print('Selected: ${counters.wordCount} words, ${counters.charCount} chars');
}

On-Demand Counting

Get counts without registering the service:
final wordCountService = WordCountService(editorState: editorState);

// Get document counts
final docCounters = wordCountService.getDocumentCounters();

// Get selection counts
final selCounters = wordCountService.getSelectionCounters();

Implementation Details

Counters Class

The Counters class holds count statistics (from /lib/src/plugins/word_count/word_counter_service.dart:24-51):
class Counters {
  const Counters({
    int wordCount = 0,
    int charCount = 0,
  })  : _wordCount = wordCount,
        _charCount = charCount;

  final int _wordCount;
  int get wordCount => _wordCount;

  final int _charCount;
  int get charCount => _charCount;
}

Word Detection

The service uses a regex pattern to detect words (from word_counter_service.dart:8-22):
// Matches all non-whitespace characters
final _wordRegex = RegExp(r"\S+");

int _wordsInString(String delta) => _wordRegex.allMatches(delta).length;
This approach:
  • Matches any sequence of non-whitespace characters
  • Supports multiple languages and scripts (unlike \w+)
  • Handles accented characters correctly
  • Works with CJK (Chinese, Japanese, Korean) characters

Character Counting

Character counting uses Unicode runes for accuracy:
// From word_counter_service.dart:271
cCount += plain.runes.length;
This ensures proper counting of:
  • Multi-byte characters
  • Emojis
  • Special Unicode characters

Debouncing

The service debounces updates to improve performance:
final wordCountService = WordCountService(
  editorState: editorState,
  debounceDuration: const Duration(milliseconds: 300), // Wait 300ms after last change
);
Set to Duration.zero to disable debouncing:
final wordCountService = WordCountService(
  editorState: editorState,
  debounceDuration: Duration.zero, // No debouncing
);

Service Lifecycle

Registration

When register() is called (from word_counter_service.dart:130-148):
  1. Performs initial count of the document
  2. Counts selection if not collapsed
  3. Attaches listeners to document and selection changes
  4. Notifies listeners of initial counts
void register() {
  if (isRunning) return;
  
  isRunning = true;
  _documentCounters = _countersFromNode(editorState.document.root);
  
  if (editorState.selection?.isCollapsed ?? false) {
    _recountOnSelectionUpdate();
  }
  
  _streamSubscription = editorState.transactionStream.listen(_onDocUpdate);
  editorState.selectionNotifier.addListener(_onSelUpdate);
}

Stopping

When stop() is called:
  1. Cancels all timers
  2. Removes listeners
  3. Resets counters to zero
  4. Notifies listeners of the reset

Building a Word Counter Widget

Simple Display

class WordCountDisplay extends StatefulWidget {
  final WordCountService service;
  
  const WordCountDisplay({required this.service});
  
  @override
  State<WordCountDisplay> createState() => _WordCountDisplayState();
}

class _WordCountDisplayState extends State<WordCountDisplay> {
  @override
  void initState() {
    super.initState();
    widget.service.addListener(_onCountChange);
  }
  
  @override
  void dispose() {
    widget.service.removeListener(_onCountChange);
    super.dispose();
  }
  
  void _onCountChange() {
    setState(() {});
  }
  
  @override
  Widget build(BuildContext context) {
    final docCounters = widget.service.documentCounters;
    final selCounters = widget.service.selectionCounters;
    
    return Row(
      children: [
        Text('Words: ${docCounters.wordCount}'),
        const SizedBox(width: 16),
        Text('Characters: ${docCounters.charCount}'),
        if (selCounters.wordCount > 0) ..[
          const SizedBox(width: 16),
          Text('Selected: ${selCounters.wordCount}'),
        ],
      ],
    );
  }
}

Status Bar Integration

class EditorWithWordCount extends StatefulWidget {
  @override
  State<EditorWithWordCount> createState() => _EditorWithWordCountState();
}

class _EditorWithWordCountState extends State<EditorWithWordCount> {
  late EditorState editorState;
  late WordCountService wordCountService;
  
  @override
  void initState() {
    super.initState();
    editorState = EditorState.blank();
    wordCountService = WordCountService(
      editorState: editorState,
      debounceDuration: const Duration(milliseconds: 300),
    );
    wordCountService.register();
  }
  
  @override
  void dispose() {
    wordCountService.stop();
    wordCountService.dispose();
    editorState.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: AppFlowyEditor(
            editorState: editorState,
          ),
        ),
        Container(
          padding: const EdgeInsets.all(8),
          color: Colors.grey[200],
          child: WordCountDisplay(service: wordCountService),
        ),
      ],
    );
  }
}

Performance Considerations

Debouncing

Use appropriate debounce duration:
  • Fast updates (100-200ms): More responsive but higher CPU usage
  • Balanced (300-500ms): Good for most use cases
  • Slow updates (500ms+): Better performance for very large documents

Large Documents

For documents with thousands of nodes:
  • Consider increasing debounce duration
  • Display loading state during recalculation
  • Update only on blur for very large documents

Selection Updates

Selection counting only triggers when selection is not collapsed:
// From word_counter_service.dart:192-202
void _recountOnSelectionUpdate() {
  // If collapsed or null, reset count
  if (editorState.selection?.isCollapsed ?? true) {
    if (_selectionCounters == _emptyCounters) {
      return;
    }
    _selectionCounters = const Counters();
    return notifyListeners();
  }
  // ... count selection
}

Advanced Usage

Custom Word Regex

Subclass WordCountService to use custom word detection:
class CustomWordCountService extends WordCountService {
  CustomWordCountService({required super.editorState});
  
  // Override to use custom regex
  final _customWordRegex = RegExp(r'\w+(\'\w+)?'); // Words with apostrophes
  
  @override
  int _wordsInString(String text) {
    return _customWordRegex.allMatches(text).length;
  }
}

Monitoring Specific Nodes

Count words in specific sections:
Counters countNodesInPath(List<int> path) {
  final node = editorState.document.nodeAtPath(path);
  if (node == null) return const Counters();
  
  return wordCountService._countersFromNode(node);
}

Complete Example

import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: EditorPage(),
    );
  }
}

class EditorPage extends StatefulWidget {
  @override
  State<EditorPage> createState() => _EditorPageState();
}

class _EditorPageState extends State<EditorPage> {
  late EditorState editorState;
  late WordCountService wordCountService;
  
  @override
  void initState() {
    super.initState();
    editorState = EditorState.blank();
    wordCountService = WordCountService(
      editorState: editorState,
      debounceDuration: const Duration(milliseconds: 300),
    );
    wordCountService.register();
    wordCountService.addListener(_updateCounts);
  }
  
  @override
  void dispose() {
    wordCountService.removeListener(_updateCounts);
    wordCountService.stop();
    wordCountService.dispose();
    editorState.dispose();
    super.dispose();
  }
  
  void _updateCounts() {
    setState(() {});
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Word Counter Demo')),
      body: Column(
        children: [
          Expanded(
            child: AppFlowyEditor(
              editorState: editorState,
            ),
          ),
          _buildStatusBar(),
        ],
      ),
    );
  }
  
  Widget _buildStatusBar() {
    final doc = wordCountService.documentCounters;
    final sel = wordCountService.selectionCounters;
    
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      color: Colors.grey[200],
      child: Row(
        children: [
          Text('Words: ${doc.wordCount}'),
          const SizedBox(width: 24),
          Text('Characters: ${doc.charCount}'),
          if (sel.wordCount > 0) ..[
            const SizedBox(width: 24),
            Text(
              'Selected: ${sel.wordCount} words, ${sel.charCount} chars',
              style: const TextStyle(fontWeight: FontWeight.bold),
            ),
          ],
        ],
      ),
    );
  }
}

See Also

Build docs developers (and LLMs) love