8
0
Просмотр исходного кода

Merge branch 'master' into t/18

Mateusz Samsel 6 лет назад
Родитель
Сommit
ea2d365aaf

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

@@ -6,9 +6,12 @@
 /* globals window */
 
 import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import BalloonEditor from '@ckeditor/ckeditor5-build-balloon/src/ckeditor';
 
 import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount';
 
 ClassicEditor.builtinPlugins.push( WordCount );
+BalloonEditor.builtinPlugins.push( WordCount );
 
 window.ClassicEditor = ClassicEditor;
+window.BalloonEditor = BalloonEditor;

+ 69 - 26
packages/ckeditor5-word-count/docs/_snippets/features/word-count-update.html

@@ -1,37 +1,80 @@
-
 <style>
-	.customized-count__color-box {
-		--hue: 56;
-		width: 20px;
-		height: 20px;
-		background-color: hsl( var( --hue ), 100%, 50% );
-		display: inline-block;
-		vertical-align: middle;
-		border-radius: 100%;
+	.demo-update {
+		border: 1px solid var(--ck-color-base-border);
+		border-radius: var(--ck-border-radius);
+		box-shadow: 2px 2px 0px hsla( 0, 0%, 0%, 0.1 );
+		margin: 1.5em 0;
+		padding: 1em;
+	}
+
+	.demo-update h3 {
+		font-size: 18px;
+		font-weight: bold;
+		margin: 0 0 .5em;
+		padding: 0;
+	}
+
+	.demo-update .ck.ck-editor__editable_inline {
+		border: 1px solid hsla( 0, 0%, 0%, 0.15 );
+		transition: background .5s ease-out;
+		min-height: 6em;
+		margin-bottom: 1em;
+	}
+
+	.demo-update__controls {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+	}
+
+	.demo-update__chart {
+		margin-right: 1em;
+	}
+
+	.demo-update__chart__circle {
+		transform: rotate(-90deg);
+		transform-origin: center;
+	}
+
+	.demo-update__chart__characters {
+		font-size: 13px;
+		font-weight: bold;
+	}
+
+	.demo-update__words {
+		flex-grow: 1;
+		opacity: .5;
 	}
 
-	.customized-count > div {
-		margin-left: 1em;
+	.demo-update__limit-close .demo-update__chart__circle {
+		stroke: hsl( 30, 100%, 52% );
 	}
 
-	.customized-count span {
-		min-width: 90px;
-		display: inline-block;
+	.demo-update__limit-exceeded .ck.ck-editor__editable_inline {
+		background: hsl( 0, 100%, 97% );
+	}
+
+	.demo-update__limit-exceeded .demo-update__chart__circle {
+		stroke: hsl( 0, 100%, 52% );
+	}
+
+	.demo-update__limit-exceeded .demo-update__chart__characters {
+		fill: hsl( 0, 100%, 52% );
 	}
 </style>
 
-<div id="demo-editor-update">
-	<p>Tourists frequently admit that <a href="https://en.wikipedia.org/wiki/Taj_Mahal">Taj Mahal</a> “simply cannot be described with words”.</p>
-</div>
-<div class="word-count customized-count">
-	<div class="customized-count__words">
-		<label>
-			<span>Words:</span>
-			<progress max="20"></progress>
-		</label>
+<div class="demo-update">
+	<h3>Post editor with word count</h3>
+	<div id="demo-update__editor">
+		<p>Tourists frequently admit that <a href="https://en.wikipedia.org/wiki/Taj_Mahal">Taj Mahal</a> “simply cannot be described with words”.</p>
 	</div>
-	<div class="customized-count__characters">
-		<span>Characters:</span>
-		<div class="customized-count__color-box"></div>
+	<div class="demo-update__controls">
+		<span class="demo-update__words"></span>
+		<svg class="demo-update__chart" viewbox="0 0 40 40" width="40" height="40" xmlns="http://www.w3.org/2000/svg">
+			<circle stroke="hsl(0, 0%, 93%)" stroke-width="3" fill="none" cx="20" cy="20" r="17" />
+			<circle class="demo-update__chart__circle" stroke="hsl(202, 92%, 59%)" stroke-width="3" stroke-dasharray="134,534" stroke-linecap="round" fill="none" cx="20" cy="20" r="17" />
+			<text class="demo-update__chart__characters" x="50%" y="50%" dominant-baseline="central" text-anchor="middle"></text>
+		</svg>
+		<button type="button" class="demo-update__send">Send post</button>
 	</div>
 </div>

+ 43 - 27
packages/ckeditor5-word-count/docs/_snippets/features/word-count-update.js

@@ -3,10 +3,11 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global window, document, console, ClassicEditor */
+/* global window, document, console, BalloonEditor */
+const sendButton = document.querySelector( '.demo-update__send' );
 
-ClassicEditor
-	.create( document.querySelector( '#demo-editor-update' ), {
+BalloonEditor
+	.create( document.querySelector( '#demo-update__editor' ), {
 		toolbar: {
 			items: [
 				'heading',
@@ -26,43 +27,58 @@ ClassicEditor
 			],
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
+		placeholder: 'Text of the post',
 		table: {
 			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
 		},
 		wordCount: {
 			onUpdate: ( () => {
-				const progressBar = document.querySelector( '.customized-count progress' );
-				const colorBox = document.querySelector( '.customized-count__color-box' );
+				const maxCharacters = 120;
+				const container = document.querySelector( '.demo-update' );
+				const progressCircle = document.querySelector( '.demo-update__chart__circle' );
+				const charactersBox = document.querySelector( '.demo-update__chart__characters' );
+				const wordsBox = document.querySelector( '.demo-update__words' );
+				const circleCircumference = Math.floor( 2 * Math.PI * progressCircle.getAttribute( 'r' ) );
 
+				// Update the UI as the content of the editor changes.
 				return data => {
-					const charactersHue = calculateHue( {
-						characters: data.characters,
-						greenUntil: 70,
-						maxCharacters: 120
-					} );
+					const currentCharacters = data.characters;
+					const currentWords = data.words;
+					const charactersProgress = currentCharacters / maxCharacters * circleCircumference;
+					const isLimitExceeded = currentCharacters > maxCharacters;
+					const isCloseToLimit = !isLimitExceeded && currentCharacters > maxCharacters * .8;
+					const circleDashArray = Math.min( charactersProgress, circleCircumference );
 
-					progressBar.value = data.words;
-					colorBox.style.setProperty( '--hue', charactersHue );
-				};
+					// Set the stroke of the circle to show the how many characters were typed.
+					progressCircle.setAttribute( 'stroke-dasharray', `${ circleDashArray },${ circleCircumference }` );
+
+					// Display the number of characters in the progress chart. When exceeded the limit,
+					// display how many characters should be removed.
+					if ( isLimitExceeded ) {
+						charactersBox.textContent = `-${ currentCharacters - maxCharacters }`;
+					} else {
+						charactersBox.textContent = currentCharacters;
+					}
+
+					wordsBox.textContent = `Words in the post: ${ currentWords }`;
 
-				// Calculates the hue based on the number of characters.
-				//
-				// For the character counter:
-				//
-				// * below greenUntil - Returns green.
-				// * between greenUntil and maxCharacters - Returns a hue between green and red.
-				// * above maxCharacters - Returns red.
-				function calculateHue( { characters, greenUntil, maxCharacters } ) {
-					const greenHue = 70;
-					const redHue = 0;
-					const progress = Math.max( 0, Math.min( 1, ( characters - greenUntil ) / ( maxCharacters - greenUntil ) ) ); // 0-1
-					const discreetProgress = Math.floor( progress * 10 ) / 10; // 0, 0.1, 0.2, ..., 1
+					// If the content length is close to the characters limit, add a CSS class to warns the user.
+					container.classList.toggle( 'demo-update__limit-close', isCloseToLimit );
 
-					return ( redHue - greenHue ) * discreetProgress + greenHue;
-				}
+					// If exceeded the characters limit, add a CSS class that makes the content's background red.
+					container.classList.toggle( 'demo-update__limit-exceeded', isLimitExceeded );
+
+					// If exceeded the characters limit, disable the send button.
+					sendButton.toggleAttribute( 'disabled', isLimitExceeded );
+				};
 			} )()
 		}
 	} )
+	.then( () => {
+		sendButton.addEventListener( 'click', () => {
+			window.alert( 'Post sent!' ); // eslint-disable-line no-alert
+		} );
+	} )
 	.catch( err => {
 		console.error( err.stack );
 	} );

+ 153 - 46
packages/ckeditor5-word-count/docs/features/word-count.md

@@ -67,51 +67,90 @@ There are two configuration options available that change the output of the word
 * If the {@link module:word-count/wordcount~WordCountConfig#displayWords `config.wordCount.displayWords`} option is set to `false`, the word counter will be hidden.
 * If the {@link module:word-count/wordcount~WordCountConfig#displayCharacters `config.wordCount.displayCharacters`} option is set to `false`, the character counter will be hidden.
 
+### Reacting to changes in statistics
+
+You can execute your custom callback every time content statistics change by defining {@link module:word-count/wordcount~WordCountConfig#onUpdate `config.wordCount.onUpdate`} in the editor configuration:
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ WordCount, ... ],
+		wordCount: {
+			onUpdate: stats => {
+				// Prints the current content statistics.
+				console.log( `Characters: ${ stats.characters }\nWords:      ${ stats.words }` );
+			}
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+**Note**: For performance reasons, your callback will be throttled and may not be up–to–date. Use {@link module:word-count/wordcount~WordCount#characters} and {@link module:word-count/wordcount~WordCount#words} plugin properties to retrieve the precise numbers on demand.
+
 ## The `update` event
 
-The {@link module:word-count/wordcount~WordCount WordCount} plugin emits the {@link module:word-count/wordcount~WordCount#event:update `WordCount#update` event}. It allows implementing customized behaviors that react to word and character count updates.
+The {@link module:word-count/wordcount~WordCount WordCount} plugin emits the {@link module:word-count/wordcount~WordCount#event:update `update` event}. It allows implementing customized behaviors that react to word and character count updates.
 
-Below you can find an example where the color of the circle goes from green to red as you approach the limit of 120 characters. The progress bar indicates the number of words.
+Below you can play with a demo post editor with a soft 120 characters limit and a progress chart below indicating how many characters are in the content. The progress chart changes its color as the limit is near or exceeded. Type in the editor to see the feature it in action. See the code used to create the demo listed later in this section.
 
 {@snippet features/word-count-update}
 
 ```js
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		// Configuration details.
+BalloonEditor
+	.create( document.querySelector( '#demo-update__editor' ), {
+		// Editor configuration.
 	} )
 	.then( editor => {
+		const maxCharacters = 120;
 		const wordCountPlugin = editor.plugins.get( 'WordCount' );
+		const container = document.querySelector( '.demo-update' );
+		const progressCircle = document.querySelector( '.demo-update__chart__circle' );
+		const charactersBox = document.querySelector( '.demo-update__chart__characters' );
+		const wordsBox = document.querySelector( '.demo-update__words' );
+		const sendButton = document.querySelector( '.demo-update__send' );
+		const circleCircumference = Math.floor( 2 * Math.PI * progressCircle.getAttribute( 'r' ) );
 
-		const progressBar = document.querySelector( '.word-count progress' );
-		const colorBox = document.querySelector( '.word-count__color-box' );
+		// Update the UI on editor load.
+		updateWordCountStatsUI( wordCountPlugin.characters, wordCountPlugin.words );
 
+		// Update the UI as the content of the editor changes.
 		wordCountPlugin.on( 'update', ( evt, data ) => {
-			const charactersHue = calculateHue( {
-				characters: data.characters,
-				greenUntil: 70,
-				maxCharacters: 120
-			} );
-
-			progressBar.value = data.words;
-			colorBox.style.setProperty( '--hue', charactersHue );
+			updateWordCountStatsUI( data.characters, data.words );
 		} );
 
-		// Calculates the hue based on the number of characters.
-		//
-		// For the character counter:
-		//
-		// * below greenUntil - Returns green.
-		// * between greenUntil and maxCharacters - Returns a hue between green and red.
-		// * above maxCharacters - Returns red.
-		function calculateHue( { characters, greenUntil, maxCharacters } ) {
-			const greenHue = 70;
-			const redHue = 0;
-			const progress = Math.max( 0, Math.min( 1, ( characters - greenUntil ) / ( maxCharacters - greenUntil ) ) ); // 0-1
-			const discreetProgress = Math.floor( progress * 10 ) / 10; // 0, 0.1, 0.2, ..., 1
-
-			return ( redHue - greenHue ) * discreetProgress + greenHue;
+		function updateWordCountStatsUI( currentCharacters, currentWords ) {
+			const charactersProgress = currentCharacters / maxCharacters * circleCircumference;
+			const isLimitExceeded = currentCharacters > maxCharacters;
+			const isCloseToLimit = !isLimitExceeded && currentCharacters > maxCharacters * .8;
+			const circleDashArray = Math.min( charactersProgress, circleCircumference );
+
+			// Set the stroke of the circle to show the how many characters were typed.
+			progressCircle.setAttribute( 'stroke-dasharray', `${ circleDashArray },${ circleCircumference }` );
+
+			// Display the number of characters in the progress chart. When exceeded the limit,
+			// display how many characters should be removed.
+			if ( isLimitExceeded ) {
+				charactersBox.textContent = `-${ currentCharacters - maxCharacters }`;
+			} else {
+				charactersBox.textContent = currentCharacters;
+			}
+
+			wordsBox.textContent = `Words in the post: ${ currentWords }`;
+
+			// If the content length is close to the characters limit, add a CSS class to warns the user.
+			container.classList.toggle( 'demo-update__limit-close', isCloseToLimit );
+
+			// If exceeded the characters limit, add a CSS class that makes the content's background red.
+			container.classList.toggle( 'demo-update__limit-exceeded', isLimitExceeded );
+
+			// If exceeded the characters limit, disable the send button.
+			sendButton.toggleAttribute( 'disabled', isLimitExceeded );
 		}
+
+		sendButton.addEventListener( 'click', () => {
+			window.alert( 'Post sent!' ); // eslint-disable-line no-alert
+		} );
 	} )
 	.catch( ... );
 ```
@@ -120,26 +159,83 @@ The HTML structure used to create the customized word and character count implem
 
 ```html
 <style>
-	.word-count__color-box {
-		width: 20px;
-		height: 20px;
-		background-color: hsl( var( --hue ), 100%, 50% );
-		display: inline-block;
-		vertical-align: middle;
-		border-radius: 100%;
+	.demo-update {
+		border: 1px solid var(--ck-color-base-border);
+		border-radius: var(--ck-border-radius);
+		box-shadow: 2px 2px 0px hsla( 0, 0%, 0%, 0.1 );
+		margin: 1.5em 0;
+		padding: 1em;
+	}
+
+	.demo-update h3 {
+		font-size: 18px;
+		font-weight: bold;
+		margin: 0 0 .5em;
+		padding: 0;
+	}
+
+	.demo-update .ck.ck-editor__editable_inline {
+		border: 1px solid hsla( 0, 0%, 0%, 0.15 );
+		transition: background .5s ease-out;
+		min-height: 6em;
+		margin-bottom: 1em;
+	}
+
+	.demo-update__controls {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+	}
+
+	.demo-update__chart {
+		margin-right: 1em;
+	}
+
+	.demo-update__chart__circle {
+		transform: rotate(-90deg);
+		transform-origin: center;
+	}
+
+	.demo-update__chart__characters {
+		font-size: 13px;
+		font-weight: bold;
+	}
+
+	.demo-update__words {
+		flex-grow: 1;
+		opacity: .5;
+	}
+
+	.demo-update__limit-close .demo-update__chart__circle {
+		stroke: hsl( 30, 100%, 52% );
+	}
+
+	.demo-update__limit-exceeded .ck.ck-editor__editable_inline {
+		background: hsl( 0, 100%, 97% );
+	}
+
+	.demo-update__limit-exceeded .demo-update__chart__circle {
+		stroke: hsl( 0, 100%, 52% );
+	}
+
+	.demo-update__limit-exceeded .demo-update__chart__characters {
+		fill: hsl( 0, 100%, 52% );
 	}
 </style>
 
-<div class="word-count">
-	<div class="word-count__words">
-		<label>
-			<span>Words:</span>
-			<progress max="20"></progress>
-		</label>
+<div class="demo-update">
+	<h3>Post editor with word count</h3>
+	<div id="demo-update__editor">
+		<p>Tourists frequently admit that <a href="https://en.wikipedia.org/wiki/Taj_Mahal">Taj Mahal</a> “simply cannot be described with words”.</p>
 	</div>
-	<div class="word-count__characters">
-		<span>Characters:</span>
-		<div class="word-count__color-box"></div>
+	<div class="demo-update__controls">
+		<span class="demo-update__words"></span>
+		<svg class="demo-update__chart" viewbox="0 0 40 40" width="40" height="40" xmlns="http://www.w3.org/2000/svg">
+			<circle stroke="hsl(0, 0%, 93%)" stroke-width="3" fill="none" cx="20" cy="20" r="17" />
+			<circle class="demo-update__chart__circle" stroke="hsl(202, 92%, 59%)" stroke-width="3" stroke-dasharray="134,534" stroke-linecap="round" fill="none" cx="20" cy="20" r="17" />
+			<text class="demo-update__chart__characters" x="50%" y="50%" dominant-baseline="central" text-anchor="middle"></text>
+		</svg>
+		<button type="button" class="demo-update__send">Send post</button>
 	</div>
 </div>
 ```
@@ -174,7 +270,18 @@ ClassicEditor
 The {@link module:word-count/wordcount~WordCount} plugin provides:
 
 * The {@link module:word-count/wordcount~WordCount#wordCountContainer} property. It returns a self-updating HTML element which is updated with the current number of words and characters in the editor. You can remove the "Words" or "Characters" counters with a proper configuration of the {@link module:word-count/wordcount~WordCountConfig#displayWords `config.wordCount.displayWords`} and {@link module:word-count/wordcount~WordCountConfig#displayCharacters `config.wordCount.displayCharacters`} options.
-* The {@link module:word-count/wordcount~WordCount#event:update `update` event}, fired whenever the plugins update the number of counted words and characters. You can run a custom callback function with updated values. Please note that the `update` event is throttled.
+* The {@link module:word-count/wordcount~WordCount#event:update `update` event}, fired whenever the plugins update the number of counted words and characters. You can use it to run a custom callback function with updated values:
+
+	```js
+	editor.plugins.get( 'WordCount' ).on( 'update', ( evt, stats ) => {
+		// Prints the current content statistics.
+		console.log( `Characters: ${ stats.characters }\nWords:      ${ stats.words }` );
+	} );
+	```
+
+	Alternatively, you can use [`editor.config.wordCount.onUpdate`](#reacting-to-changes-in-statistics) to register a similar callback in editor configuration.
+
+	**Note**: For performance reasons, the `update` event is throttled so the statistics may not be up–to–date. Use {@link module:word-count/wordcount~WordCount#characters} and {@link module:word-count/wordcount~WordCount#words} plugin properties to retrieve the precise numbers on demand.
 * The {@link module:word-count/wordcount~WordCount#characters} and {@link module:word-count/wordcount~WordCount#words} properties from which you can retrieve the stats at any moment.
 
 <info-box>

+ 45 - 7
packages/ckeditor5-word-count/src/wordcount.js

@@ -69,6 +69,22 @@ export default class WordCount extends Plugin {
 		 */
 		this.set( 'words', 0 );
 
+		// Don't wait for #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() );
+				}
+			},
+			words: {
+				get() {
+					return ( this.words = this._getWords() );
+				}
+			}
+		} );
+
 		/**
 		 * The label used to display the words value in the {@link #wordCountContainer output container}.
 		 *
@@ -238,6 +254,31 @@ export default class WordCount extends Plugin {
 		return this._outputView.element;
 	}
 
+	/**
+	 * Determines the number of characters in the current editor's model.
+	 *
+	 * @private
+	 * @returns {Number}
+	 */
+	_getCharacters() {
+		const txt = modelElementToPlainText( this.editor.model.document.getRoot() );
+
+		return txt.replace( /\n/g, '' ).length;
+	}
+
+	/**
+	 * Determines the number of words in the current editor's model.
+	 *
+	 * @private
+	 * @returns {Number}
+	 */
+	_getWords() {
+		const txt = modelElementToPlainText( this.editor.model.document.getRoot() );
+		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}.
@@ -246,15 +287,12 @@ export default class WordCount extends Plugin {
 	 * @fires update
 	 */
 	_refreshStats() {
-		const txt = modelElementToPlainText( this.editor.model.document.getRoot() );
-		const detectedWords = txt.match( this._wordsMatchRegExp ) || [];
-
-		this.characters = txt.replace( /\n/g, '' ).length;
-		this.words = detectedWords.length;
+		const words = this.words = this._getWords();
+		const characters = this.characters = this._getCharacters();
 
 		this.fire( 'update', {
-			words: this.words,
-			characters: this.characters
+			words,
+			characters
 		} );
 	}
 }

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

@@ -28,6 +28,14 @@ ClassicEditor
 		document.getElementById( 'destroy-editor' ).addEventListener( 'click', () => {
 			editor.destroy();
 		} );
+
+		editor.plugins.get( 'WordCount' ).on( 'change:words', ( evt, name, value ) => {
+			console.log( 'WordCount:change:words', value );
+		} );
+
+		editor.plugins.get( 'WordCount' ).on( 'change:characters', ( evt, name, value ) => {
+			console.log( 'WordCount:change:characters', value );
+		} );
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 73 - 7
packages/ckeditor5-word-count/tests/wordcount.js

@@ -39,15 +39,81 @@ describe( 'WordCount', () => {
 	} );
 
 	describe( 'constructor()', () => {
-		it( 'has defined "words" property', () => {
-			expect( wordCountPlugin.words ).to.equal( 0 );
+		describe( '#words property', () => {
+			it( 'is defined', () => {
+				expect( wordCountPlugin.words ).to.equal( 0 );
+			} );
+
+			it( 'returns the number of words right away', () => {
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+				expect( wordCountPlugin.words ).to.equal( 2 );
+
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world</paragraph>' );
+				expect( wordCountPlugin.words ).to.equal( 2 );
+
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text></paragraph>' );
+				expect( wordCountPlugin.words ).to.equal( 1 );
+
+				setModelData( model, '' );
+				expect( wordCountPlugin.words ).to.equal( 0 );
+			} );
+
+			it( 'is observable', done => {
+				const spy = sinon.spy();
+
+				wordCountPlugin.on( 'change:words', spy );
+
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text></paragraph>' );
+
+				setTimeout( () => {
+					// The #update event fired once because it is throttled.
+					sinon.assert.calledOnce( spy );
+					expect( spy.firstCall.args[ 2 ] ).to.equal( 1 );
+
+					done();
+				}, DELAY );
+			} );
 		} );
 
-		it( 'has defined "characters" property', () => {
-			expect( wordCountPlugin.characters ).to.equal( 0 );
+		describe( '#characters property', () => {
+			it( 'is defined', () => {
+				expect( wordCountPlugin.characters ).to.equal( 0 );
+			} );
+
+			it( 'returns the number of characters right away', () => {
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+				expect( wordCountPlugin.characters ).to.equal( 12 );
+
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world</paragraph>' );
+				expect( wordCountPlugin.characters ).to.equal( 11 );
+
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text></paragraph>' );
+				expect( wordCountPlugin.characters ).to.equal( 5 );
+
+				setModelData( model, '' );
+				expect( wordCountPlugin.characters ).to.equal( 0 );
+			} );
+
+			it( 'is observable', done => {
+				const spy = sinon.spy();
+
+				wordCountPlugin.on( 'change:characters', spy );
+
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text> world.</paragraph>' );
+				setModelData( model, '<paragraph><$text foo="true">Hello</$text></paragraph>' );
+
+				setTimeout( () => {
+					// The #update event fired once because it is throttled.
+					sinon.assert.calledOnce( spy );
+					expect( spy.firstCall.args[ 2 ] ).to.equal( 5 );
+
+					done();
+				}, DELAY );
+			} );
 		} );
 
-		it( 'has "WordCount" plugin name', () => {
+		it( 'has a name', () => {
 			expect( WordCount.pluginName ).to.equal( 'WordCount' );
 		} );
 	} );
@@ -130,8 +196,8 @@ describe( 'WordCount', () => {
 			} );
 		} );
 
-		describe( 'update event', () => {
-			it( 'fires update event with actual amount of characters and words', () => {
+		describe( '#update event', () => {
+			it( 'fires with the actual number of characters and words', () => {
 				const fake = sinon.fake();
 				wordCountPlugin.on( 'update', fake );