wordcount.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module word-count/wordcount
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import { modelElementToPlainText } from './utils';
  10. import { throttle, isElement } from 'lodash-es';
  11. import View from '@ckeditor/ckeditor5-ui/src/view';
  12. import Template from '@ckeditor/ckeditor5-ui/src/template';
  13. import env from '@ckeditor/ckeditor5-utils/src/env';
  14. /**
  15. * The word count plugin.
  16. *
  17. * This plugin calculates all words and characters in all {@link module:engine/model/text~Text text nodes} available in the model.
  18. * It also provides an HTML element that updates its state whenever the editor content is changed.
  19. *
  20. * The model's data is first converted to plain text using {@link module:word-count/utils.modelElementToPlainText}.
  21. * The number of words and characters in your text are determined based on the created plain text. Please keep in mind
  22. * that every block in the editor is separated with a newline character, which is included in the calculation.
  23. *
  24. * Here are some examples of how the word and character calculations are made:
  25. *
  26. * <paragraph>foo</paragraph>
  27. * <paragraph>bar</paragraph>
  28. * // Words: 2, Characters: 7
  29. *
  30. * <paragraph><$text bold="true">foo</$text>bar</paragraph>
  31. * // Words: 1, Characters: 6
  32. *
  33. * <paragraph>*&^%)</paragraph>
  34. * // Words: 0, Characters: 5
  35. *
  36. * <paragraph>foo(bar)</paragraph>
  37. * //Words: 2, Characters: 8
  38. *
  39. * <paragraph>12345</paragraph>
  40. * // Words: 1, Characters: 5
  41. *
  42. * @extends module:core/plugin~Plugin
  43. */
  44. export default class WordCount extends Plugin {
  45. /**
  46. * @inheritDoc
  47. */
  48. constructor( editor ) {
  49. super( editor );
  50. /**
  51. * The number of characters in the editor.
  52. *
  53. * @observable
  54. * @readonly
  55. * @member {Number} module:word-count/wordcount~WordCount#characters
  56. */
  57. this.set( 'characters', 0 );
  58. /**
  59. * The number of words in the editor.
  60. *
  61. * @observable
  62. * @readonly
  63. * @member {Number} module:word-count/wordcount~WordCount#words
  64. */
  65. this.set( 'words', 0 );
  66. // Don't wait for the #update event to set the value of the properties but obtain it right away.
  67. // This way, accessing the properties directly returns precise numbers, e.g. for validation, etc.
  68. // If not accessed directly, the properties will be refreshed upon #update anyway.
  69. Object.defineProperties( this, {
  70. characters: {
  71. get() {
  72. return ( this.characters = this._getCharacters() );
  73. }
  74. },
  75. words: {
  76. get() {
  77. return ( this.words = this._getWords() );
  78. }
  79. }
  80. } );
  81. /**
  82. * The label used to display the words value in the {@link #wordCountContainer output container}.
  83. *
  84. * @observable
  85. * @private
  86. * @readonly
  87. * @member {String} module:word-count/wordcount~WordCount#_wordsLabel
  88. */
  89. this.set( '_wordsLabel' );
  90. /**
  91. * The label used to display the characters value in the {@link #wordCountContainer output container}.
  92. *
  93. * @observable
  94. * @private
  95. * @readonly
  96. * @member {String} module:word-count/wordcount~WordCount#_charactersLabel
  97. */
  98. this.set( '_charactersLabel' );
  99. /**
  100. * The configuration of this plugin.
  101. *
  102. * @private
  103. * @type {Object}
  104. */
  105. this._config = editor.config.get( 'wordCount' ) || {};
  106. /**
  107. * The reference to a {@link module:ui/view~View view object} that contains the self-updating HTML container.
  108. *
  109. * @private
  110. * @readonly
  111. * @type {module:ui/view~View}
  112. */
  113. this._outputView;
  114. /**
  115. * A regular expression used to recognize words in the editor's content.
  116. *
  117. * @readonly
  118. * @private
  119. * @type {RegExp}
  120. */
  121. this._wordsMatchRegExp = env.features.isRegExpUnicodePropertySupported ?
  122. // Usage of regular expression literal cause error during build (ckeditor/ckeditor5-dev#534).
  123. // Groups:
  124. // {L} - Any kind of letter from any language.
  125. // {N} - Any kind of numeric character in any script.
  126. // {M} - A character intended to be combined with another character (e.g. accents, umlauts, enclosing boxes, etc.).
  127. // {Pd} - Any kind of hyphen or dash.
  128. // {Pc} - A punctuation character such as an underscore that connects words.
  129. new RegExp( '[\\p{L}\\p{N}\\p{M}\\p{Pd}\\p{Pc}]+', 'gu' ) :
  130. /[_\-a-zA-Z0-9À-ž]+/gu;
  131. }
  132. /**
  133. * @inheritDoc
  134. */
  135. static get pluginName() {
  136. return 'WordCount';
  137. }
  138. /**
  139. * @inheritDoc
  140. */
  141. init() {
  142. const editor = this.editor;
  143. editor.model.document.on( 'change:data', throttle( this._refreshStats.bind( this ), 250 ) );
  144. if ( typeof this._config.onUpdate == 'function' ) {
  145. this.on( 'update', ( evt, data ) => {
  146. this._config.onUpdate( data );
  147. } );
  148. }
  149. if ( isElement( this._config.container ) ) {
  150. this._config.container.appendChild( this.wordCountContainer );
  151. }
  152. }
  153. /**
  154. * @inheritDoc
  155. */
  156. destroy() {
  157. if ( this._outputView ) {
  158. this._outputView.element.remove();
  159. this._outputView.destroy();
  160. }
  161. super.destroy();
  162. }
  163. /**
  164. * Creates a self-updating HTML element. Repeated executions return the same element.
  165. * The returned element has the following HTML structure:
  166. *
  167. * <div class="ck ck-word-count">
  168. * <div class="ck-word-count__words">Words: 4</div>
  169. * <div class="ck-word-count__characters">Characters: 28</div>
  170. * </div>
  171. *
  172. * @type {HTMLElement}
  173. */
  174. get wordCountContainer() {
  175. const editor = this.editor;
  176. const t = editor.t;
  177. const displayWords = editor.config.get( 'wordCount.displayWords' );
  178. const displayCharacters = editor.config.get( 'wordCount.displayCharacters' );
  179. const bind = Template.bind( this, this );
  180. const children = [];
  181. if ( !this._outputView ) {
  182. this._outputView = new View();
  183. if ( displayWords || displayWords === undefined ) {
  184. this.bind( '_wordsLabel' ).to( this, 'words', words => {
  185. return t( 'Words: %0', [ words ] );
  186. } );
  187. children.push( {
  188. tag: 'div',
  189. children: [
  190. {
  191. text: [ bind.to( '_wordsLabel' ) ]
  192. }
  193. ],
  194. attributes: {
  195. class: 'ck-word-count__words'
  196. }
  197. } );
  198. }
  199. if ( displayCharacters || displayCharacters === undefined ) {
  200. this.bind( '_charactersLabel' ).to( this, 'characters', words => {
  201. return t( 'Characters: %0', [ words ] );
  202. } );
  203. children.push( {
  204. tag: 'div',
  205. children: [
  206. {
  207. text: [ bind.to( '_charactersLabel' ) ]
  208. }
  209. ],
  210. attributes: {
  211. class: 'ck-word-count__characters'
  212. }
  213. } );
  214. }
  215. this._outputView.setTemplate( {
  216. tag: 'div',
  217. attributes: {
  218. class: [
  219. 'ck',
  220. 'ck-word-count'
  221. ]
  222. },
  223. children
  224. } );
  225. this._outputView.render();
  226. }
  227. return this._outputView.element;
  228. }
  229. /**
  230. * Determines the number of characters in the current editor's model.
  231. *
  232. * @private
  233. * @returns {Number}
  234. */
  235. _getCharacters() {
  236. const txt = modelElementToPlainText( this.editor.model.document.getRoot() );
  237. return txt.replace( /\n/g, '' ).length;
  238. }
  239. /**
  240. * Determines the number of words in the current editor's model.
  241. *
  242. * @private
  243. * @returns {Number}
  244. */
  245. _getWords() {
  246. const txt = modelElementToPlainText( this.editor.model.document.getRoot() );
  247. const detectedWords = txt.match( this._wordsMatchRegExp ) || [];
  248. return detectedWords.length;
  249. }
  250. /**
  251. * Determines the number of words and characters in the current editor's model and assigns it to {@link #characters} and {@link #words}.
  252. * It also fires the {@link #event:update}.
  253. *
  254. * @private
  255. * @fires update
  256. */
  257. _refreshStats() {
  258. const words = this.words = this._getWords();
  259. const characters = this.characters = this._getCharacters();
  260. this.fire( 'update', {
  261. words,
  262. characters
  263. } );
  264. }
  265. }
  266. /**
  267. * An event fired after {@link #words} and {@link #characters} are updated.
  268. *
  269. * @event update
  270. * @param {Object} data
  271. * @param {Number} data.words The number of words in the current model.
  272. * @param {Number} data.characters The number of characters in the current model.
  273. */
  274. /**
  275. * The configuration of the word count feature.
  276. *
  277. * ClassicEditor
  278. * .create( {
  279. * wordCount: ... // Word count feature configuration.
  280. * } )
  281. * .then( ... )
  282. * .catch( ... );
  283. *
  284. * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
  285. *
  286. * @interface module:word-count/wordcount~WordCountConfig
  287. */
  288. /**
  289. * The configuration of the word count feature.
  290. * It is introduced by the {@link module:word-count/wordcount~WordCount} feature.
  291. *
  292. * Read more in {@link module:word-count/wordcount~WordCountConfig}.
  293. *
  294. * @member {module:word-count/wordcount~WordCountConfig} module:core/editor/editorconfig~EditorConfig#wordCount
  295. */
  296. /**
  297. * This option allows for hiding the word counter. The element obtained through
  298. * {@link module:word-count/wordcount~WordCount#wordCountContainer} will only preserve
  299. * the characters part. Word counter is displayed by default when this configuration option is not defined.
  300. *
  301. * const wordCountConfig = {
  302. * displayWords: false
  303. * };
  304. *
  305. * The configuration above will result in the following container:
  306. *
  307. * <div class="ck ck-word-count">
  308. * <div class="ck-word-count__characters">Characters: 28</div>
  309. * </div>
  310. *
  311. * @member {Boolean} module:word-count/wordcount~WordCountConfig#displayWords
  312. */
  313. /**
  314. * This option allows for hiding the character counter. The element obtained through
  315. * {@link module:word-count/wordcount~WordCount#wordCountContainer} will only preserve
  316. * the words part. Character counter is displayed by default when this configuration option is not defined.
  317. *
  318. * const wordCountConfig = {
  319. * displayCharacters: false
  320. * };
  321. *
  322. * The configuration above will result in the following container:
  323. *
  324. * <div class="ck ck-word-count">
  325. * <div class="ck-word-count__words">Words: 4</div>
  326. * </div>
  327. *
  328. * @member {Boolean} module:word-count/wordcount~WordCountConfig#displayCharacters
  329. */
  330. /**
  331. * This configuration takes a function that is executed whenever the word count plugin updates its values.
  332. * This function is called with one argument, which is an object with the `words` and `characters` keys containing
  333. * the number of detected words and characters in the document.
  334. *
  335. * const wordCountConfig = {
  336. * onUpdate: function( stats ) {
  337. * doSthWithWordNumber( stats.words );
  338. * doSthWithCharacterNumber( stats.characters );
  339. * }
  340. * };
  341. *
  342. * @member {Function} module:word-count/wordcount~WordCountConfig#onUpdate
  343. */
  344. /**
  345. * Allows for providing the HTML element that the
  346. * {@link module:word-count/wordcount~WordCount#wordCountContainer word count container} will be appended to automatically.
  347. *
  348. * const wordCountConfig = {
  349. * container: document.getElementById( 'container-for-word-count' );
  350. * };
  351. *
  352. * @member {HTMLElement} module:word-count/wordcount~WordCountConfig#container
  353. */