Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Introduce word count plugin #2

Merged
merged 35 commits into from
Jul 1, 2019
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f4fd8d
Add required dependencies.
Jun 3, 2019
ad4684e
Add plugins base.
Jun 6, 2019
76d7485
Add some simple unit test.
Jun 6, 2019
8e59ff9
Fix docs typo.
Jun 10, 2019
1f0a05b
Small typo and white characters improvements.
Jun 11, 2019
faff505
Add unit tests for word count plugin.
Jun 11, 2019
a92751d
Update manual test description ad functions.
Jun 11, 2019
fe53c68
Extend documentation for word count plugin.
Jun 11, 2019
a7b485f
Add docs article describing the feature.
Jun 11, 2019
5b5ab05
Docs improvements.
Jun 12, 2019
88858fd
Improve snippet's style.
Jun 12, 2019
244f4ad
Fix typos in snippets.
Jun 26, 2019
5470728
Minor tweaks to feature and tests with review.
Jun 26, 2019
0a9fd82
Simplify model to plain text transformation.
Jun 27, 2019
fb86541
Improve way of translations usage.
Jun 27, 2019
06a2ca4
Add class for output contianer
Jun 27, 2019
334afa9
Add classes to output container.
Jun 27, 2019
0942361
Add unit test for more complicated structures to convert.
Jun 27, 2019
f49ac89
Fix localization tests.
Jun 28, 2019
0a7318d
Add unit test for integration wordcount with selection change in the …
Jun 28, 2019
fbdd2a8
Add support for configuration option, 'onUpdate' and 'container'
Jun 28, 2019
4053902
Update manual test to use new configuration optiona.
Jun 28, 2019
22050ba
Add unit test covers new config options. Remove test which has no sense.
Jun 28, 2019
da6b718
Apply suggestions from code review
msamsel Jun 28, 2019
2677157
Apply suggestions from code review
msamsel Jun 28, 2019
c42606d
Remove unit test, which checks existence of private property.
Jun 28, 2019
dae254f
Apply suggestions from code review
msamsel Jul 1, 2019
57bfe06
Transform method into getter. Update docs and tests.
Jul 1, 2019
8c63c46
Fixed a typo.
Reinmar Jul 1, 2019
6510e03
Improved text formatting.
Reinmar Jul 1, 2019
efb7224
Doc string wording.
Reinmar Jul 1, 2019
f90e5ef
Tests: Updated property name.
Reinmar Jul 1, 2019
cfcff7d
Added missing dependencies.
Reinmar Jul 1, 2019
55c2ca4
Wording.
Reinmar Jul 1, 2019
2aa90c8
Fixed code snippets.
Reinmar Jul 1, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
14 changes: 14 additions & 0 deletions docs/_snippets/features/build-word-count-source.js
Original file line number Diff line number Diff line change
@@ -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 changes: 38 additions & 0 deletions docs/_snippets/features/word-count-update.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@

<style>
.customized-counter__color-box {
--hue: 180;
width: 20px;
height: 20px;
background-color: hsl( var( --hue ), 100%, 50% );
display: inline-block;
}

.customized-counter {
border: 3px solid #333;
padding-left: 5px;
margin-bottom: 15px;
}

.customized-counter > 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-counter">
<div class="customized-counter__words">
<label>Words:
<progress value="48" max='100'></progress>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/'/"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/48/42

</label>
</div>
<div class="customized-counter__characters">
Characters:
<div class="customized-counter__color-box"></div>
</div>
</div>
48 changes: 48 additions & 0 deletions docs/_snippets/features/word-count-update.js
Original file line number Diff line number Diff line change
@@ -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-counter progress' );
const colorBox = document.querySelector( '.customized-counter__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 changes: 13 additions & 0 deletions docs/_snippets/features/word-count.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<style>
.word-counter {
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-counter" class="word-counter">
</div>
39 changes: 39 additions & 0 deletions docs/_snippets/features/word-count.js
Original file line number Diff line number Diff line change
@@ -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-counter' ).appendChild( editor.plugins.get( 'WordCount' ).getWordCountContainer() );
} )
.catch( err => {
console.error( err.stack );
} );
108 changes: 108 additions & 0 deletions docs/features/word-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
category: features
---

{@snippet features/build-word-count-source}

# Word count

The {@link module:wordcount/wordcount~WordCount} features provide a possibility to track the number of words and characters written in the editor.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The {@link module:wordcount/wordcount~WordCount} features provide a possibility to track the number of words and characters written in the editor.
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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may left typical .catch( ... ); (empty / not related block) here as the demo below have different contents then this snippet.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTOH maybe just one line that you need some HTML element to place the word counter somewhere, ie in a div. (so an example without the <div id="editor">.

Also, I'd use an id not a class here as we have id="editor" here and I'm for using Ids then classes for placing such objects.

</div>
<div class="word-count">
</div>
```

```js
ClassicEditor
.create( document.querySelector( '#editor' ), {
// configuration details
msamsel marked this conversation as resolved.
Show resolved Hide resolved
} )
.then( editor => {
const wordCountPlugin = editor.plugins.get( 'WordCount' );
const wordCountWrapper = document.querySelector( '.word-count' );

wordCountWrapper.appendChild( wordCounterPlugin.getWordCountContainer() );
} )
.catch( err => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be .catch( ... ); in here

console.error( err.stack );
} );
```

## Configuring options
msamsel marked this conversation as resolved.
Show resolved Hide resolved

There are two options which change the output container. If there is set {@link module:wordcount/wordcount~WordCountConfig#displayWords} to `false`, then the section with word counter is removed from self-updating output container. In a similar way works second option {@link module:wordcount/wordcount~WordCountConfig#displayCharacters} with character container.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
There are two options which change the output container. If there is set {@link module:wordcount/wordcount~WordCountConfig#displayWords} to `false`, then the section with word counter is removed from self-updating output container. In a similar way works second option {@link module:wordcount/wordcount~WordCountConfig#displayCharacters} with character container.
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 counter 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 a model. This allows on having own callback with customized behavior reacting on this change.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Word count feature emits an {@link module:wordcount/wordcount~WordCount#event:update update event} whenever there is a change in a model. This allows on having own callback with customized behavior reacting on this change.
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, payload ) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we use data in such constructs.

// payload is an object with "words" and "characters" field
doSthWithNewWordsNumber( payload.words );
doSthWithNewCharactersNumber( payload.characters );
} );

} )
.catch( err => {
console.error( err.stack );
} );
```

## 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#getWordCountContainer} method. It returns a self-updating HTML Element which might be used to track the current amount of words and characters in the editor. There is a possibility to remove "Words" or "Characters" counter with proper configuration of {@link module:wordcount/wordcount~WordCountConfig#displayWords} and {@link module:wordcount/wordcount~WordCountConfig#displayCharacters},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* {@link module:wordcount/wordcount~WordCount#getWordCountContainer} method. It returns a self-updating HTML Element which might be used to track the current amount of words and characters in the editor. There is a possibility to remove "Words" or "Characters" counter with proper configuration of {@link module:wordcount/wordcount~WordCountConfig#displayWords} and {@link module:wordcount/wordcount~WordCountConfig#displayCharacters},
* {@link module:wordcount/wordcount~WordCount#getWordCountContainer} 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 provides more versatile option to handle changes of words' and characters' number. There is a possibility to run own callback function with updated values.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd go with more general description: "which is fired whenever the plugins update the number of counted words and characters. Note this event is throttled...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rephrase it a little bite in a more generic manner.


<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.
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
"ckeditor",
"ckeditor5",
"ckeditor 5",
"ckeditor5-feature",
"ckeditor5-plugin"
"ckeditor5-feature",
"ckeditor5-plugin"
],
"dependencies": {
"@ckeditor/ckeditor5-core": "^12.1.0",
"lodash-es": "^4.17.10"
},
"devDependencies": {
"@ckeditor/ckeditor5-engine": "^13.1.1",
"@ckeditor/ckeditor5-paragraph": "^11.0.2",
"@ckeditor/ckeditor5-utils": "^12.1.1",
"eslint": "^5.5.0",
"eslint-config-ckeditor5": "^1.0.11",
"husky": "^1.3.1",
Expand Down
42 changes: 42 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @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
*/

/**
* Function walks through all the model's nodes. It obtains a plain text from each {@link module:engine/model/text~Text}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description of implementation ;)

Something like: "Returns plain text representation of an element and it's children. The blocks are separated by a newline (\n ).

* and {@link module:engine/model/textproxy~TextProxy}. All sections, which are not a text, are separated with a new line (`\n`).
*
Reinmar marked this conversation as resolved.
Show resolved Hide resolved
* **Note:** Function walks through the entire model. There should be considered throttling during usage.
*
* @param {module:engine/model/node~Node} node
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrong param: should be Element - the Node doesn't have `getChildren()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually needs to be Item because it's also called with Text and TextProxy.

But, at the same time, you're completely right that neither of them have getChildren(). Which means that if we'd use a statically typed language we'd need to typecast here or solve it differently. That's why I wrote in the ticket that you reported that we should wait with such polishing until we use TS.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks so - I wrote that ticket after this review actually. I worry that we can have more such mistype declaration in the docs for Node/Element/Item trio.

* @returns {String} Plain text representing model's data
*/
export function modelElementToPlainText( node ) {
let text = '';

if ( node.is( 'text' ) || node.is( 'textProxy' ) ) {
text += node.data;
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove the indentation here: return on if and remove the else block. Also you could move let text = '' after if then.

The other question is do you consider TreeWalker here? I don't know if its much overhead (currently parsing a 1M characters (few paragraphs) takes below 30 ms so it is performant enough).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made simple code with treewalker and performance dropped significantly.
In my case it was slow down ~50 times, I'm not sure if it's case of my algorithm with tree walker or tree walker itself, however, it seems that walking through children recursively is far more efficient.

  1. current code:
    Screenshot 2019-06-27 at 11 00 25

  2. simple tree walking:

		const txt = ( editor => {
			const position = new Position( editor.model.document.getRoot(), [ 0 ] );
			const walker = new TreeWalker( { startPosition: position } );

			let endTagIndicator = false;
			let outputText = '';

			for ( const element of walker ) {
				if ( element.typy === 'elementStart' && endTagIndicator ) {
					outputText += '\n';
					endTagIndicator = false;
				} else if ( element.type === 'elementEnd' ) {
					endTagIndicator = true;
				} else if ( element.type === 'text' ) {
					outputText += element.item.data;
				}
			}

			return outputText;
		} )( this.editor );

Screenshot 2019-06-27 at 10 59 20

let prev = null;

for ( const child of node.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;
}
Loading