Jelajahi Sumber

Merge pull request #2 from ckeditor/t/1

Feature: Introduce word count feature. Closes #1. Closes ckeditor/ckeditor5#1301.
Piotrek Koszuliński 6 tahun lalu
induk
melakukan
d247a7a247

+ 0 - 0
packages/ckeditor5-word-count/docs/_snippets/features/build-word-count-source.html


+ 14 - 0
packages/ckeditor5-word-count/docs/_snippets/features/build-word-count-source.js

@@ -0,0 +1,14 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+
+import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount';
+
+ClassicEditor.builtinPlugins.push( WordCount );
+
+window.ClassicEditor = ClassicEditor;

+ 38 - 0
packages/ckeditor5-word-count/docs/_snippets/features/word-count-update.html

@@ -0,0 +1,38 @@
+
+<style>
+	.customized-count__color-box {
+		--hue: 180;
+		width: 20px;
+		height: 20px;
+		background-color: hsl( var( --hue ), 100%, 50% );
+		display: inline-block;
+	}
+
+	.customized-count {
+		border: 3px solid #333;
+		padding-left: 5px;
+		margin-bottom: 15px;
+	}
+
+	.customized-count > div {
+		display: inline-block;
+		width: 50%;
+		margin-left: 0;
+		margin-right: 0;
+	}
+</style>
+
+<div id="demo-editor-update">
+		<p>A <strong>black hole</strong> is a region of <a href="https://en.wikipedia.org/wiki/Spacetime">spacetime</a> exhibiting <a href="https://en.wikipedia.org/wiki/Gravitation">gravitational</a> acceleration so strong that nothing—no <a href="https://en.wikipedia.org/wiki/Particle">particles</a> or even <a href="https://en.wikipedia.org/wiki/Electromagnetic_radiation">electromagnetic radiation</a> such as <a href="https://en.wikipedia.org/wiki/Light">light</a>—can escape from it.<a href="https://en.wikipedia.org/wiki/Black_hole#cite_note-6">[6]</a> The theory of <a href="https://en.wikipedia.org/wiki/General_relativity">general relativity</a> predicts that a sufficiently compact <a href="https://en.wikipedia.org/wiki/Mass">mass</a> can deform <a href="https://en.wikipedia.org/wiki/Spacetime">spacetime</a> to form a black hole.</p>
+</div>
+<div class="customized-count">
+	<div class="customized-count__words">
+		<label>Words:
+			<progress value="42" max="100"></progress>
+		</label>
+	</div>
+	<div class="customized-count__characters">
+		Characters:
+		<div class="customized-count__color-box"></div>
+	</div>
+</div>

+ 48 - 0
packages/ckeditor5-word-count/docs/_snippets/features/word-count-update.js

@@ -0,0 +1,48 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global window, document, console, ClassicEditor */
+
+ClassicEditor
+	.create( document.querySelector( '#demo-editor-update' ), {
+		toolbar: {
+			items: [
+				'heading',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'blockQuote',
+				'link',
+				'|',
+				'mediaEmbed',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		}
+	} )
+	.then( editor => {
+		const wordCountPlugin = editor.plugins.get( 'WordCount' );
+
+		const progressBar = document.querySelector( '.customized-count progress' );
+		const colorBox = document.querySelector( '.customized-count__color-box' );
+
+		wordCountPlugin.on( 'update', updateHandler );
+
+		function updateHandler( evt, payload ) {
+			progressBar.value = payload.words;
+			colorBox.style.setProperty( '--hue', payload.characters * 3 );
+		}
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+

+ 13 - 0
packages/ckeditor5-word-count/docs/_snippets/features/word-count.html

@@ -0,0 +1,13 @@
+<style>
+	.word-count {
+		border: 3px solid #333;
+		padding-left: 5px;
+		margin-bottom: 15px;
+	}
+</style>
+<div id="demo-editor">
+	<p>The <strong>Battle of Westerplatte</strong> was one of the first battles in Germany's <a href="https://en.wikipedia.org/wiki/Invasion_of_Poland">invasion of Poland</a>, marking the start of <a href="https://en.wikipedia.org/wiki/World_War_II">World War II</a> in <a href="https://en.wikipedia.org/wiki/Europe">Europe</a>. Beginning on 1 September 1939, <a href="https://en.wikipedia.org/wiki/Nazi_Germany">German</a> <a href="https://en.wikipedia.org/wiki/German_Army_(Wehrmacht)">army</a>, <a href="https://en.wikipedia.org/wiki/Kriegsmarine">naval</a> and <a href="https://en.wikipedia.org/wiki/Luftwaffe">air forces</a> and <a href="https://en.wikipedia.org/wiki/Free_City_of_Danzig_Police">Danzig police</a> assaulted <a href="https://en.wikipedia.org/wiki/Poland">Poland</a>'s Military Transit Depot (<i>Wojskowa Składnica Tranzytowa</i>, or <i>WST</i>) on the <a href="https://en.wikipedia.org/wiki/Westerplatte">Westerplatte</a> peninsula in the harbor of the <a href="https://en.wikipedia.org/wiki/Free_City_of_Danzig">Free City of Danzig</a>. The Poles held out for seven days and repelled 13 assaults that included <a href="https://en.wikipedia.org/wiki/Dive_bomber">dive-bomber</a> attacks and naval shelling.</p>
+	<p>Westerplatte's defense served as an inspiration for the <a href="https://en.wikipedia.org/wiki/Polish_Army">Polish Army</a> and people in the face of German advances elsewhere, and is still regarded as a symbol of resistance in modern Poland.</p>
+</div>
+<div id="demo-word-count" class="word-count">
+</div>

+ 39 - 0
packages/ckeditor5-word-count/docs/_snippets/features/word-count.js

@@ -0,0 +1,39 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document, window, console, ClassicEditor */
+
+ClassicEditor
+	.create( document.querySelector( '#demo-editor' ), {
+		toolbar: {
+			items: [
+				'heading',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'blockQuote',
+				'link',
+				'|',
+				'mediaEmbed',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		document.getElementById( 'demo-word-count' ).appendChild( editor.plugins.get( 'WordCount' ).wordCountContainer );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 104 - 0
packages/ckeditor5-word-count/docs/features/word-count.md

@@ -0,0 +1,104 @@
+---
+category: features
+---
+
+{@snippet features/build-word-count-source}
+
+# Word count
+
+The {@link module:wordcount/wordcount~WordCount} feature provides a possibility to track the number of words and characters written in the editor.
+
+## Demo
+
+{@snippet features/word-count}
+
+```html
+<div id="editor">
+	<p>Hello world.</p>
+</div>
+<div id="word-count">
+</div>
+```
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		// Configuration details.
+	} )
+	.then( editor => {
+		const wordCountPlugin = editor.plugins.get( 'WordCount' );
+		const wordCountWrapper = document.getElementById( 'word-count' );
+
+		wordCountWrapper.appendChild( wordCountPlugin.wordCountContainer );
+	} )
+	.catch( ... );
+```
+
+## Configuration options
+
+There are two options which change the output container. If the {@link module:wordcount/wordcount~WordCountConfig#displayWords} is set to to `false`, then the section with word count is hidden. Similarly, when the {@link module:wordcount/wordcount~WordCountConfig#displayCharacters} is set to `false` it will hide the character counter.
+
+## Update event
+
+Word count feature emits an {@link module:wordcount/wordcount~WordCount#event:update update event} whenever there is a change in the model. This allows implementing customized behavior that reacts to word count updates.
+
+Below you can find an example, where the background color of a square is changed according to the number of characters in the editor. There is also a progress bar which indicates how many words is in it (the maximal value of the progress bar is set to 100, however, you can write further and progress bar remain in the maximal state).
+
+{@snippet features/word-count-update}
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		// configuration details
+	} )
+	.then( editor => {
+		const wordCountPlugin = editor.plugins.get( 'WordCount' );
+
+		wordCountPlugin.on( 'update', ( evt, data ) => {
+			// data is an object with "words" and "characters" field
+			doSthWithNewWordsNumber( data.words );
+			doSthWithNewCharactersNumber( data.characters );
+		} );
+
+	} )
+	.catch( ... );
+```
+
+## Installation
+
+To add this feature to your rich-text editor, install the [`@ckeditor/ckeditor5-word-count`](https://www.npmjs.com/package/@ckeditor/ckeditor5-word-count) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-word-count
+```
+
+And add it to your plugin list configuration:
+
+```js
+import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ WordCount, ... ],
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box info>
+	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+
+## Common API
+
+The {@link module:wordcount/wordcount~WordCount} plugin provides:
+  * {@link module:wordcount/wordcount~WordCount#wordCountContainer} method. It returns a self-updating HTML element which is updated with the current number of words and characters in the editor. There is a possibility to remove "Words" or "Characters" counters with proper configuration of {@link module:wordcount/wordcount~WordCountConfig#displayWords} and {@link module:wordcount/wordcount~WordCountConfig#displayCharacters},
+  * {@link module:wordcount/wordcount~WordCount#event:update update event} which is fired whenever the plugins update the number of counted words and characters. There is a possibility to run own callback function with updated values. Please note that update event is throttled.
+
+<info-box>
+	We recommend using the official {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} for development and debugging. It will give you tons of useful information about the state of the editor such as internal data structures, selection, commands, and many more.
+</info-box>
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5-word-count.

+ 4 - 0
packages/ckeditor5-word-count/lang/contexts.json

@@ -0,0 +1,4 @@
+{
+	"Words: %0": "Label showing the number of words in the editor content.",
+	"Characters: %0": "Label showing the number of characters in the editor content."
+}

+ 15 - 2
packages/ckeditor5-word-count/package.json

@@ -6,12 +6,25 @@
     "ckeditor",
     "ckeditor",
     "ckeditor5",
     "ckeditor5",
     "ckeditor 5",
     "ckeditor 5",
-	"ckeditor5-feature",
-	"ckeditor5-plugin"
+    "ckeditor5-feature",
+    "ckeditor5-plugin"
   ],
   ],
   "dependencies": {
   "dependencies": {
+    "@ckeditor/ckeditor5-core": "^12.1.0",
+    "@ckeditor/ckeditor5-ui": "^13.0.0",
+    "lodash-es": "^4.17.10"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@ckeditor/ckeditor5-basic-styles": "^11.1.1",
+    "@ckeditor/ckeditor5-block-quote": "^11.1.0",
+    "@ckeditor/ckeditor5-editor-classic": "^12.1.1",
+    "@ckeditor/ckeditor5-engine": "^13.1.1",
+    "@ckeditor/ckeditor5-enter": "^11.0.2",
+    "@ckeditor/ckeditor5-link": "^11.0.2",
+    "@ckeditor/ckeditor5-list": "^12.0.2",
+    "@ckeditor/ckeditor5-paragraph": "^11.0.2",
+    "@ckeditor/ckeditor5-table": "^13.0.0",
+    "@ckeditor/ckeditor5-utils": "^12.1.1",
     "eslint": "^5.5.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",
     "husky": "^1.3.1",

+ 38 - 0
packages/ckeditor5-word-count/src/utils.js

@@ -0,0 +1,38 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module wordcount/utils
+ */
+
+/**
+ * Returns a plain text representation of an element and its children.
+ *
+ * @param {module:engine/model/element~Element} element
+ * @returns {String} Plain text representing model's data
+ */
+export function modelElementToPlainText( element ) {
+	if ( element.is( 'text' ) || element.is( 'textProxy' ) ) {
+		return element.data;
+	}
+
+	let text = '';
+	let prev = null;
+
+	for ( const child of element.getChildren() ) {
+		const childText = modelElementToPlainText( child );
+
+		// If last block was finish, start from new line.
+		if ( prev && prev.is( 'element' ) ) {
+			text += '\n';
+		}
+
+		text += childText;
+
+		prev = child;
+	}
+
+	return text;
+}

+ 334 - 0
packages/ckeditor5-word-count/src/wordcount.js

@@ -0,0 +1,334 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module wordcount/wordcount
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { modelElementToPlainText } from './utils';
+import { throttle, isElement } from 'lodash-es';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import Template from '@ckeditor/ckeditor5-ui/src/template';
+
+/**
+ * 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, which updates its states whenever the editor's content is changed.
+ *
+ * Firstly model's data are convert to plain text using {@link module:wordcount/utils.modelElementToPlainText}.
+ * Based on such created plain text there are determined amount of words and characters in your text. Please keep in mind
+ * that every block in the editor is separate with a newline character, which is included in the calculation.
+ *
+ * Few examples of how word and character calculation are made:
+ *
+ * 		<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: 2, Characters: 8
+ *
+ * 		<paragraph>12345</paragraph>
+ * 		// Words: 1, Characters: 5
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class WordCount extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * The number of characters in the editor.
+		 *
+		 * @observable
+		 * @readonly
+		 * @member {Number} module:wordcount/wordcount~WordCount#characters
+		 */
+		this.set( 'characters', 0 );
+
+		/**
+		 * The number of words in the editor.
+		 *
+		 * @observable
+		 * @readonly
+		 * @member {Number} module:wordcount/wordcount~WordCount#words
+		 */
+		this.set( 'words', 0 );
+
+		/**
+		 * Label used to display words value in {@link #wordCountContainer output container}
+		 *
+		 * @observable
+		 * @private
+		 * @readonly
+		 * @member {String} module:wordcount/wordcount~WordCount#_wordsLabel
+		 */
+		this.set( '_wordsLabel' );
+
+		/**
+		 * Label used to display characters value in {@link #wordCountContainer output container}
+		 *
+		 * @observable
+		 * @private
+		 * @readonly
+		 * @member {String} module:wordcount/wordcount~WordCount#_charactersLabel
+		 */
+		this.set( '_charactersLabel' );
+
+		/**
+		 * The configuration of this plugins.
+		 *
+		 * @private
+		 * @type {Object}
+		 */
+		this._config = editor.config.get( 'wordCount' ) || {};
+
+		/**
+		 * A reference to a {@link module:ui/view~View view object} which contains self-updating HTML container.
+		 *
+		 * @private
+		 * @readonly
+		 * @type {module:ui/view~View}
+		 */
+		this._outputView;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'WordCount';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.model.document.on( 'change:data', throttle( this._calculateWordsAndCharacters.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() {
+		this._outputView.element.remove();
+		this._outputView.destroy();
+
+		super.destroy();
+	}
+
+	/**
+	 * Creates self-updated HTML element. Repeated executions return the same element.
+	 * Returned element has followed HTML structure:
+	 *
+	 * 		<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>
+	 *
+	 * @type {HTMLElement}
+	 */
+	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;
+	}
+
+	/**
+	 * 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 {@link #event:update}.
+	 *
+	 * @private
+	 * @fires update
+	 */
+	_calculateWordsAndCharacters() {
+		const txt = modelElementToPlainText( this.editor.model.document.getRoot() );
+
+		this.characters = txt.length;
+
+		this.words = ( txt.match( /[_a-zA-Z0-9À-ž]+/gu ) || [] ).length;
+
+		this.fire( 'update', {
+			words: this.words,
+			characters: this.characters
+		} );
+	}
+}
+
+/**
+ * Event fired after {@link #words} and {@link #characters} are updated.
+ *
+ * @event update
+ * @param {Object} data
+ * @param {Number} data.words number of words in current model
+ * @param {Number} data.characters number of characters in current model
+ */
+
+/**
+ * The configuration of the word count feature.
+ *
+ *		ClassicEditor
+ *			.create( {
+ *				wordCount: ... // Word count feature configuration.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface module:wordcount/wordcount~WordCountConfig
+ */
+
+/**
+ * The configuration of the word count feature.
+ * It is introduced by the {@link module:wordcount/wordcount~WordCount} feature.
+ *
+ * Read more in {@link module:wordcount/wordcount~WordCountConfig}.
+ *
+ * @member {module:wordcount/wordcount~WordCountConfig} module:core/editor/editorconfig~EditorConfig#wordCount
+ */
+
+/**
+ * This option allows for hiding the word count. The element obtained through
+ * {@link module:wordcount/wordcount~WordCount#wordCountContainer} will only preserve
+ * the characters part. word count is displayed by default when this configuration option is not defined.
+ *
+ *		const wordCountConfig = {
+ *			displayWords: false
+ *		}
+ *
+ * The mentioned configuration will result with the followed container:
+ *
+ *		<div class="ck ck-word-count">
+ *			<div class="ck-word-count__characters">Characters: 28</div>
+ *		</div>
+ *
+ * @member {Boolean} module:wordcount/wordcount~WordCountConfig#displayWords
+ */
+
+/**
+ * This option allows for hiding the character counter. The element obtained through
+ * {@link module:wordcount/wordcount~WordCount#wordCountContainer} will only preserve
+ * the words part. Character counter is displayed by default when this configuration option is not defined.
+ *
+ *		const wordCountConfig = {
+ *			displayCharacters: false
+ *		}
+ *
+ * The mentioned configuration will result in the following container
+ *
+ *		<div class="ck ck-word-count">
+ *			<div class="ck-word-count__words">Words: 4</div>
+ *		</div>
+ *
+ * @member {Boolean} module:wordcount/wordcount~WordCountConfig#displayCharacters
+ */
+
+/**
+ * This configuration takes a function, which is executed whenever the word-count plugin updates its values.
+ * This function is called with one argument, which is an object with `words` and `characters` keys containing
+ * a number of detected words and characters in the document.
+ *
+ *		const wordCountConfig = {
+ *			onUpdate: function( stats ) {
+ *				doSthWithWordNumber( stats.words );
+ *				doSthWithCharacterNumber( stats.characters );
+ *			}
+ *		}
+ *
+ * @member {Function} module:wordcount/wordcount~WordCountConfig#onUpdate
+ */
+
+/**
+ * This option allows on providing an HTML element where
+ * {@link module:wordcount/wordcount~WordCount#wordCountContainer word count container} will be appended automatically.
+ *
+ *		const wordCountConfig = {
+ *			container: document.getElementById( 'container-for-word-count' );
+ *		}
+ *
+ * @member {HTMLElement} module:wordcount/wordcount~WordCountConfig#container
+ */

+ 12 - 0
packages/ckeditor5-word-count/tests/manual/wordcount.html

@@ -0,0 +1,12 @@
+<style>
+	body {
+		width: 50vw;
+	}
+</style>
+<h2>Test editor</h2>
+<button id="destroy-editor">Destroy editor</button>
+<div id="editor">
+	Hello world.
+</div>
+<div id="words-container">
+</div>

+ 34 - 0
packages/ckeditor5-word-count/tests/manual/wordcount.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import WordCount from '../../src/wordcount';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, WordCount ],
+		toolbar: [
+			'heading', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'link', 'undo', 'redo'
+		],
+		wordCount: {
+			onUpdate: values => {
+				console.log( `Values from 'onUpdate': ${ JSON.stringify( values ) }` );
+			},
+			container: document.getElementById( 'words-container' )
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		document.getElementById( 'destroy-editor' ).addEventListener( 'click', () => {
+			editor.destroy();
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 8 - 0
packages/ckeditor5-word-count/tests/manual/wordcount.md

@@ -0,0 +1,8 @@
+1. Try to type in the editor. The container below should be automatically updated with the current amount of words and characters.
+2. Special characters are treated as separators for words. For example
+	* `Hello world` - 2 words
+	* `Hello(World)` - 2 words
+	* `Hello\nWorld` - 2 words
+3. Numbers are treated as words.
+4. There are logged values of `WordCount:event-update` in the console. Values should change in same way as container in html.
+5. After destroying the editor, the container with word and character values should be also removed.

+ 131 - 0
packages/ckeditor5-word-count/tests/utils.js

@@ -0,0 +1,131 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { modelElementToPlainText } from '../src/utils';
+
+import Element from '@ckeditor/ckeditor5-engine/src/model/element';
+import Text from '@ckeditor/ckeditor5-engine/src/model/text';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting';
+import ListEditing from '@ckeditor/ckeditor5-list/src/listediting';
+import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
+
+describe( 'utils', () => {
+	describe( 'modelElementToPlainText()', () => {
+		it( 'should extract only plain text', () => {
+			const text1 = new Text( 'Foo' );
+			const text2 = new Text( 'Bar', { bold: true } );
+			const text3 = new Text( 'Baz', { bold: true, underline: true } );
+
+			const innerElement1 = new Element( 'paragraph', null, [ text1 ] );
+			const innerElement2 = new Element( 'paragraph', null, [ text2, text3 ] );
+
+			const mainElement = new Element( 'container', null, [ innerElement1, innerElement2 ] );
+
+			expect( modelElementToPlainText( mainElement ) ).to.equal( 'Foo\nBarBaz' );
+		} );
+
+		describe( 'complex structures', () => {
+			let editor, model;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Enter, ShiftEnter, Paragraph, BoldEditing, LinkEditing, BlockQuoteEditing, ListEditing, TableEditing ]
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+					} );
+			} );
+
+			afterEach( () => {
+				editor.destroy();
+			} );
+
+			it( 'extracts plain text from blockqoutes', () => {
+				setModelData( model, '<blockQuote>' +
+						'<paragraph>Hello</paragraph>' +
+						'<listItem listIndent="0" listType="numbered">world</listItem>' +
+						'<listItem listIndent="0" listType="numbered">foo</listItem>' +
+						'<paragraph>bar</paragraph>' +
+					'</blockQuote>' );
+
+				expect( modelElementToPlainText( model.document.getRoot() ) ).to.equal( 'Hello\nworld\nfoo\nbar' );
+			} );
+
+			it( 'extracts plain text from tables', () => {
+				setModelData( model, '<table>' +
+						'<tableRow>' +
+							'<tableCell>' +
+								'<paragraph>Foo</paragraph>' +
+							'</tableCell>' +
+							'<tableCell>' +
+								'<paragraph>Bar</paragraph>' +
+							'</tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell>' +
+								'<paragraph>Baz</paragraph>' +
+							'</tableCell>' +
+							'<tableCell>' +
+								'<paragraph>Foo</paragraph>' +
+							'</tableCell>' +
+						'</tableRow>' +
+					'</table>' );
+
+				expect( modelElementToPlainText( model.document.getRoot() ) ).to.equal( 'Foo\nBar\nBaz\nFoo' );
+			} );
+
+			it( 'extracts plain text with soft break', () => {
+				setModelData( model, '<paragraph>Foo<softBreak></softBreak>bar</paragraph>' );
+
+				expect( modelElementToPlainText( model.document.getRoot() ) ).to.equal( 'Foo\nbar' );
+			} );
+
+			it( 'extracts plain text with inline styles', () => {
+				setModelData( model, '<paragraph>F<$text bold="true">oo</$text><$text href="url">Ba</$text>r</paragraph>' );
+
+				expect( modelElementToPlainText( model.document.getRoot() ) ).to.equal( 'FooBar' );
+			} );
+
+			it( 'extracts plain text from mixed structure', () => {
+				setModelData( model, '<paragraph>' +
+						'<$text bold="true">111</$text><$text href="url" bold="true">222</$text>333' +
+					'</paragraph><blockQuote>' +
+						'<paragraph>444<softBreak></softBreak>555</paragraph>' +
+						'<table>' +
+							'<tableRow>' +
+								'<tableCell><paragraph>666</paragraph></tableCell>' +
+								'<tableCell><paragraph>7<$text bold="true">7</$text>7</paragraph></tableCell>' +
+							'</tableRow>' +
+							'<tableRow>' +
+								'<tableCell><paragraph>888</paragraph></tableCell>' +
+								'<tableCell><paragraph>999</paragraph></tableCell>' +
+							'</tableRow>' +
+						'</table>' +
+					'</blockQuote><table>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>000</paragraph></tableCell>' +
+							'<tableCell><blockQuote>' +
+								'<listItem listIndent="0" listType="numbered">111</listItem>' +
+								'<listItem listIndent="0" listType="numbered">222</listItem>' +
+							'</blockQuote></tableCell>' +
+						'</tableRow>' +
+					'</table>' );
+
+				expect( modelElementToPlainText( model.document.getRoot() ) ).to.equal(
+					'111222333\n444\n555\n666\n777\n888\n999\n000\n111\n222'
+				);
+			} );
+		} );
+	} );
+} );

+ 333 - 0
packages/ckeditor5-word-count/tests/wordcount.js

@@ -0,0 +1,333 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global HTMLElement, setTimeout, document */
+
+import WordCount from '../src/wordcount';
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+
+// Delay related to word-count throttling.
+const DELAY = 255;
+
+describe( 'WordCount', () => {
+	testUtils.createSinonSandbox();
+
+	let wordCountPlugin, editor, model;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ WordCount, Paragraph ]
+		} )
+			.then( _editor => {
+				editor = _editor;
+				model = editor.model;
+				wordCountPlugin = editor.plugins.get( 'WordCount' );
+
+				model.schema.extend( '$text', { allowAttributes: 'foo' } );
+			} );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'has defined "words" property', () => {
+			expect( wordCountPlugin.words ).to.equal( 0 );
+		} );
+
+		it( 'has defined "characters" property', () => {
+			expect( wordCountPlugin.characters ).to.equal( 0 );
+		} );
+
+		it( 'has "WordCount" plugin name', () => {
+			expect( WordCount.pluginName ).to.equal( 'WordCount' );
+		} );
+	} );
+
+	describe( 'functionality', () => {
+		it( 'counts words', () => {
+			expect( wordCountPlugin.words ).to.equal( 0 );
+
+			setModelData( model, '<paragraph>Foo(bar)baz</paragraph>' +
+				'<paragraph><$text foo="true">Hello</$text> world.</paragraph>' +
+				'<paragraph>1234</paragraph>' +
+				'<paragraph>(-@#$%^*())</paragraph>' );
+
+			wordCountPlugin._calculateWordsAndCharacters();
+
+			expect( wordCountPlugin.words ).to.equal( 6 );
+		} );
+
+		it( 'counts characters', () => {
+			setModelData( model, '<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+
+			wordCountPlugin._calculateWordsAndCharacters();
+
+			expect( wordCountPlugin.characters ).to.equal( 12 );
+		} );
+
+		describe( 'update event', () => {
+			it( 'fires update event with actual amount of characters and words', () => {
+				const fake = sinon.fake();
+				wordCountPlugin.on( 'update', fake );
+
+				wordCountPlugin._calculateWordsAndCharacters();
+
+				sinon.assert.calledOnce( fake );
+				sinon.assert.calledWithExactly( fake, sinon.match.any, { words: 0, characters: 0 } );
+
+				// _calculateWordsAndCharacters is throttled, so for this test case is run manually
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+				wordCountPlugin._calculateWordsAndCharacters();
+
+				sinon.assert.calledTwice( fake );
+				sinon.assert.calledWithExactly( fake, sinon.match.any, { words: 2, characters: 12 } );
+			} );
+		} );
+	} );
+
+	describe( 'self-updating element', () => {
+		let container;
+		beforeEach( () => {
+			container = wordCountPlugin.wordCountContainer;
+		} );
+
+		it( 'provides html element', () => {
+			expect( container ).to.be.instanceof( HTMLElement );
+		} );
+
+		it( 'provided element has proper structure', () => {
+			expect( container.tagName ).to.equal( 'DIV' );
+			expect( container.classList.contains( 'ck' ) ).to.be.true;
+			expect( container.classList.contains( 'ck-word-count' ) ).to.be.true;
+
+			const children = Array.from( container.children );
+			expect( children.length ).to.equal( 2 );
+			expect( children[ 0 ].tagName ).to.equal( 'DIV' );
+			expect( children[ 0 ].innerHTML ).to.equal( 'Words: 0' );
+			expect( children[ 1 ].tagName ).to.equal( 'DIV' );
+			expect( children[ 1 ].innerHTML ).to.equal( 'Characters: 0' );
+		} );
+
+		it( 'updates container content', () => {
+			expect( container.innerText ).to.equal( 'Words: 0Characters: 0' );
+
+			setModelData( model, '<paragraph>Foo(bar)baz</paragraph>' +
+				'<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+
+			wordCountPlugin._calculateWordsAndCharacters();
+
+			// There is \n between paragraph which has to be included into calculations
+			expect( container.innerText ).to.equal( 'Words: 5Characters: 24' );
+		} );
+
+		it( 'subsequent calls provides the same element', () => {
+			const newContainer = wordCountPlugin.wordCountContainer;
+
+			expect( container ).to.equal( newContainer );
+		} );
+
+		describe( 'destroy()', () => {
+			it( 'html element is removed', done => {
+				const frag = document.createDocumentFragment();
+
+				frag.appendChild( container );
+
+				expect( frag.querySelector( '*' ) ).to.be.instanceof( HTMLElement );
+
+				editor.destroy()
+					.then( () => {
+						expect( frag.querySelector( '*' ) ).to.be.null;
+					} )
+					.then( done )
+					.catch( done );
+			} );
+
+			it( 'method is called', done => {
+				const spy = sinon.spy( wordCountPlugin, 'destroy' );
+
+				editor.destroy()
+					.then( () => {
+						sinon.assert.calledOnce( spy );
+					} )
+					.then( done )
+					.catch( done );
+			} );
+		} );
+	} );
+
+	describe( '_calculateWordsAndCharacters and throttle', () => {
+		beforeEach( done => {
+			// We need to flush initial throttle value after editor's initialization
+			setTimeout( done, DELAY );
+		} );
+
+		it( 'gets update after model data change', done => {
+			const fake = sinon.fake();
+
+			wordCountPlugin.on( 'update', fake );
+
+			// Initial change in model should be immediately reflected in word-count
+			setModelData( model, '<paragraph>Hello world.</paragraph>' );
+
+			sinon.assert.calledOnce( fake );
+			sinon.assert.calledWith( fake, sinon.match.any, { words: 2, characters: 12 } );
+
+			// Subsequent updates should be throttle and run with last parameters
+			setTimeout( () => {
+				sinon.assert.calledTwice( fake );
+				sinon.assert.calledWith( fake, sinon.match.any, { words: 2, characters: 9 } );
+
+				done();
+			}, DELAY );
+
+			setModelData( model, '<paragraph>Hello world</paragraph>' );
+			setModelData( model, '<paragraph>Hello worl</paragraph>' );
+			setModelData( model, '<paragraph>Hello wor</paragraph>' );
+		} );
+
+		it( 'is not update after selection change', done => {
+			setModelData( model, '<paragraph>Hello[] world.</paragraph>' );
+
+			const fake = sinon.fake();
+			const fakeSelectionChange = sinon.fake();
+
+			wordCountPlugin.on( 'update', fake );
+			model.document.on( 'change', fakeSelectionChange );
+
+			model.change( writer => {
+				const range = writer.createRange( new Position( model.document.getRoot(), [ 0, 1 ] ) );
+
+				writer.setSelection( range );
+			} );
+
+			model.change( writer => {
+				const range = writer.createRange( new Position( model.document.getRoot(), [ 0, 10 ] ) );
+
+				writer.setSelection( range );
+			} );
+
+			setTimeout( () => {
+				sinon.assert.notCalled( fake );
+				sinon.assert.called( fakeSelectionChange );
+
+				done();
+			}, DELAY );
+		} );
+	} );
+
+	describe( 'custom config options', () => {
+		it( 'displayWords = false', done => {
+			VirtualTestEditor.create( {
+				plugins: [ WordCount, Paragraph ],
+				wordCount: {
+					displayWords: false
+				}
+			} )
+				.then( editor => {
+					const wordCountPlugin = editor.plugins.get( 'WordCount' );
+					const container = wordCountPlugin.wordCountContainer;
+
+					expect( container.innerText ).to.equal( 'Characters: 0' );
+				} )
+				.then( done )
+				.catch( done );
+		} );
+
+		it( 'displayCharacters = false', done => {
+			VirtualTestEditor.create( {
+				plugins: [ WordCount, Paragraph ],
+				wordCount: {
+					displayCharacters: false
+				}
+			} )
+				.then( editor => {
+					const wordCountPlugin = editor.plugins.get( 'WordCount' );
+					const container = wordCountPlugin.wordCountContainer;
+
+					expect( container.innerText ).to.equal( 'Words: 0' );
+				} )
+				.then( done )
+				.catch( done );
+		} );
+
+		it( 'should call function registered under config.wordCount.onUpdate', () => {
+			const fake = sinon.fake();
+			return VirtualTestEditor.create( {
+				plugins: [ WordCount, Paragraph ],
+				wordCount: {
+					onUpdate: fake
+				}
+			} )
+				.then( editor => {
+					sinon.assert.calledWithExactly( fake, { words: 0, characters: 0 } );
+
+					setModelData( editor.model, '<paragraph>Foo Bar</paragraph>' );
+				} )
+				.then( () => new Promise( resolve => {
+					setTimeout( resolve, DELAY );
+				} ) )
+				.then( () => {
+					sinon.assert.calledWithExactly( fake, { words: 2, characters: 7 } );
+				} );
+		} );
+
+		it( 'should append word count container in element referenced in config.wordCount.container', () => {
+			const element = document.createElement( 'div' );
+
+			expect( element.children.length ).to.equal( 0 );
+
+			return VirtualTestEditor.create( {
+				plugins: [ WordCount, Paragraph ],
+				wordCount: {
+					container: element
+				}
+			} )
+				.then( editor => {
+					expect( element.children.length ).to.equal( 1 );
+
+					const wordCountPlugin = editor.plugins.get( 'WordCount' );
+
+					expect( element.firstElementChild ).to.equal( wordCountPlugin.wordCountContainer );
+				} );
+		} );
+	} );
+
+	describe( 'translations', () => {
+		before( () => {
+			addTranslations( 'pl', {
+				'Words: %0': 'Słowa: %0',
+				'Characters: %0': 'Znaki: %0'
+			} );
+			addTranslations( 'en', {
+				'Words: %0': 'Words: %0',
+				'Characters: %0': 'Characters: %0'
+			} );
+		} );
+
+		after( () => {
+			clearTranslations();
+		} );
+
+		it( 'applies proper language translations', done => {
+			VirtualTestEditor.create( {
+				plugins: [ WordCount, Paragraph ],
+				language: 'pl'
+			} )
+				.then( editor => {
+					const wordCountPlugin = editor.plugins.get( 'WordCount' );
+					const container = wordCountPlugin.wordCountContainer;
+
+					expect( container.innerText ).to.equal( 'Słowa: 0Znaki: 0' );
+				} )
+				.then( done )
+				.catch( done );
+		} );
+	} );
+} );
+