@ckeditor/ckeditor5-word-count
Version:
Word and character count feature for CKEditor 5.
230 lines (229 loc) • 7.88 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { Template, View } from 'ckeditor5/src/ui.js';
import { env } from 'ckeditor5/src/utils.js';
import { modelElementToPlainText } from './utils.js';
import { throttle, isElement } from 'es-toolkit/compat';
/**
* The word count plugin.
*
* This plugin calculates all words and characters in all {@link module:engine/model/text~Text text nodes} available in the model.
* It also provides an HTML element that updates its state whenever the editor content is changed.
*
* The model's data is first converted to plain text using {@link module:word-count/utils~modelElementToPlainText}.
* The number of words and characters in your text are determined based on the created plain text. Please keep in mind
* that every block in the editor is separated with a newline character, which is included in the calculation.
*
* Here are some examples of how the word and character calculations are made:
*
* ```html
* <paragraph>foo</paragraph>
* <paragraph>bar</paragraph>
* // Words: 2, Characters: 7
*
* <paragraph><$text bold="true">foo</$text>bar</paragraph>
* // Words: 1, Characters: 6
*
* <paragraph>*&^%)</paragraph>
* // Words: 0, Characters: 5
*
* <paragraph>foo(bar)</paragraph>
* //Words: 1, Characters: 8
*
* <paragraph>12345</paragraph>
* // Words: 1, Characters: 5
* ```
*/
export default class WordCount extends Plugin {
/**
* The configuration of this plugin.
*/
_config;
/**
* The reference to a {@link module:ui/view~View view object} that contains the self-updating HTML container.
*/
_outputView;
/**
* A regular expression used to recognize words in the editor's content.
*/
_wordsMatchRegExp;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this.set('characters', 0);
this.set('words', 0);
// Don't wait for the #update event to set the value of the properties but obtain it right away.
// This way, accessing the properties directly returns precise numbers, e.g. for validation, etc.
// If not accessed directly, the properties will be refreshed upon #update anyway.
Object.defineProperties(this, {
characters: {
get() {
return (this.characters = this._getCharacters(this._getText()));
}
},
words: {
get() {
return (this.words = this._getWords(this._getText()));
}
}
});
this.set('_wordsLabel', undefined);
this.set('_charactersLabel', undefined);
this._config = editor.config.get('wordCount') || {};
this._outputView = undefined;
this._wordsMatchRegExp = env.features.isRegExpUnicodePropertySupported ?
// Usage of regular expression literal cause error during build (ckeditor/ckeditor5-dev#534).
// Groups:
// {L} - Any kind of letter from any language.
// {N} - Any kind of numeric character in any script.
new RegExp('([\\p{L}\\p{N}]+\\S?)+', 'gu') :
/([a-zA-Z0-9À-ž]+\S?)+/gu;
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'WordCount';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.model.document.on('change:data', throttle(this._refreshStats.bind(this), 250));
if (typeof this._config.onUpdate == 'function') {
this.on('update', (evt, data) => {
this._config.onUpdate(data);
});
}
if (isElement(this._config.container)) {
this._config.container.appendChild(this.wordCountContainer);
}
}
/**
* @inheritDoc
*/
destroy() {
if (this._outputView) {
this._outputView.element.remove();
this._outputView.destroy();
}
super.destroy();
}
/**
* Creates a self-updating HTML element. Repeated executions return the same element.
* The returned element has the following HTML structure:
*
* ```html
* <div class="ck ck-word-count">
* <div class="ck-word-count__words">Words: 4</div>
* <div class="ck-word-count__characters">Characters: 28</div>
* </div>
* ```
*/
get wordCountContainer() {
const editor = this.editor;
const t = editor.t;
const displayWords = editor.config.get('wordCount.displayWords');
const displayCharacters = editor.config.get('wordCount.displayCharacters');
const bind = Template.bind(this, this);
const children = [];
if (!this._outputView) {
this._outputView = new View();
if (displayWords || displayWords === undefined) {
this.bind('_wordsLabel').to(this, 'words', words => {
return t('Words: %0', words);
});
children.push({
tag: 'div',
children: [
{
text: [bind.to('_wordsLabel')]
}
],
attributes: {
class: 'ck-word-count__words'
}
});
}
if (displayCharacters || displayCharacters === undefined) {
this.bind('_charactersLabel').to(this, 'characters', words => {
return t('Characters: %0', words);
});
children.push({
tag: 'div',
children: [
{
text: [bind.to('_charactersLabel')]
}
],
attributes: {
class: 'ck-word-count__characters'
}
});
}
this._outputView.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-word-count'
]
},
children
});
this._outputView.render();
}
return this._outputView.element;
}
_getText() {
let txt = '';
for (const root of this.editor.model.document.getRoots()) {
if (txt !== '') {
// Add a delimiter, so words from each root are treated independently.
txt += '\n';
}
txt += modelElementToPlainText(root);
}
return txt;
}
/**
* Determines the number of characters in the current editor's model.
*/
_getCharacters(txt) {
return txt.replace(/\n/g, '').length;
}
/**
* Determines the number of words in the current editor's model.
*/
_getWords(txt) {
const detectedWords = txt.match(this._wordsMatchRegExp) || [];
return detectedWords.length;
}
/**
* Determines the number of words and characters in the current editor's model and assigns it to {@link #characters} and {@link #words}.
* It also fires the {@link #event:update}.
*
* @fires update
*/
_refreshStats() {
const txt = this._getText();
const words = this.words = this._getWords(txt);
const characters = this.characters = this._getCharacters(txt);
this.fire('update', {
words,
characters
});
}
}