diff --git a/client/lib/posts/post-edit-store.js b/client/lib/posts/post-edit-store.js index 8bb22b49b5037f..2750f7da8f155d 100644 --- a/client/lib/posts/post-edit-store.js +++ b/client/lib/posts/post-edit-store.js @@ -229,6 +229,7 @@ function setRawContent( content ) { if ( PostEditStore.isDirty() !== isDirty || PostEditStore.hasContent() !== hasContent ) { PostEditStore.emit( 'change' ); } + PostEditStore.emit( 'rawContentChange' ); } } diff --git a/client/lib/text-utils/Makefile b/client/lib/text-utils/Makefile new file mode 100644 index 00000000000000..c51b1c60d0d524 --- /dev/null +++ b/client/lib/text-utils/Makefile @@ -0,0 +1,12 @@ +NODE_BIN := $(shell npm bin) +MOCHA ?= $(NODE_BIN)/mocha +BASE_DIR := $(NODE_BIN)/../.. +NODE_PATH := test:$(BASE_DIR)/client:$(BASE_DIR)/shared +COMPILERS ?= js:babel/register +REPORTER ?= spec +UI ?= bdd + +test: + @NODE_ENV=test NODE_PATH=$(NODE_PATH) $(MOCHA) --compilers $(COMPILERS) --reporter $(REPORTER) --ui $(UI) + +.PHONY: test diff --git a/client/lib/text-utils/index.js b/client/lib/text-utils/index.js new file mode 100644 index 00000000000000..5fe806b8c500fa --- /dev/null +++ b/client/lib/text-utils/index.js @@ -0,0 +1,27 @@ +function countWords( content ) { + // Adapted from TinyMCE wordcount plugin: + // https://github.com/tinymce/tinymce/blob/4.2.6/js/tinymce/plugins/wordcount/plugin.js + + if ( content && typeof content === 'string' ) { + // convert ellipses to spaces, remove HTML tags, and remove space chars + content = content.replace( /\.\.\./g, ' ' ); + content = content.replace( /<.[^<>]*?>/g, ' ' ); + content = content.replace( / | /gi, ' ' ); + + // deal with HTML entities + content = content.replace( /(\w+)(&#?[a-z0-9]+;)+(\w+)/i, '$1$3' ); // strip entities inside words + content = content.replace( /&.+?;/g, ' ' ); // turn all other entities into spaces + + // remove numbers and punctuation + content = content.replace( /[0-9.(),;:!?%#$?\x27\x22_+=\\\/\-]*/g, '' ); + + const words = content.match( /[\w\u2019\x27\-\u00C0-\u1FFF]+/g ); + if ( words ) { + return words.length; + } + } + + return 0; +} + +export default { countWords }; diff --git a/client/lib/text-utils/test/index.js b/client/lib/text-utils/test/index.js new file mode 100644 index 00000000000000..949d2e6d8020da --- /dev/null +++ b/client/lib/text-utils/test/index.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import textUtils from '../'; + +// Adapted from TinyMCE word count tests: +// https://github.com/tinymce/tinymce/blob/4.2.6/tests/plugins/wordcount.js + +describe( 'textUtils', () => { + describe( 'wordCount', () => { + it( 'should return 0 for blank content', () => { + expect( textUtils.countWords( + '' + ) ).to.equal( 0 ); + } ); + + it( 'should strip HTML tags and count words for a simple sentence', () => { + expect( textUtils.countWords( + '

My sentence is this.

' + ) ).to.equal( 4 ); + } ); + + it( 'should not count dashes', () => { + expect( textUtils.countWords( + '

Something -- ok

' + ) ).to.equal( 2 ); + } ); + + it( 'should not count asterisks or other non-word characters', () => { + expect( textUtils.countWords( + '

* something\n\u00b7 something else

' + ) ).to.equal( 3 ); + } ); + + it( 'should not count numbers', () => { + expect( textUtils.countWords( + '

Something 123 ok

' + ) ).to.equal( 2 ); + } ); + + it( 'should not count HTML entities', () => { + expect( textUtils.countWords( + '

It’s my life – – – don\'t you forget.

' + ) ).to.equal( 6 ); + } ); + + it( 'should count hyphenated words as one word', () => { + expect( textUtils.countWords( + '

Hello some-word here.

' + ) ).to.equal( 3 ); + } ); + + it( 'should count words between blocks as two words', () => { + expect( textUtils.countWords( + '

Hello

world

' + ) ).to.equal( 2 ); + } ); + } ); +} ); diff --git a/client/post-editor/editor-word-count/index.jsx b/client/post-editor/editor-word-count/index.jsx new file mode 100644 index 00000000000000..ccce8a5812dbe8 --- /dev/null +++ b/client/post-editor/editor-word-count/index.jsx @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import React from 'react/addons'; + +/** + * Internal dependencies + */ +import PostEditStore from 'lib/posts/post-edit-store'; +import userModule from 'lib/user'; +import Count from 'components/count'; +import textUtils from 'lib/text-utils'; + +/** + * Module variables + */ +const user = userModule(); + +export default React.createClass( { + displayName: 'EditorWordCount', + + mixins: [ React.addons.PureRenderMixin ], + + getInitialState() { + return { + rawContent: '' + }; + }, + + componentWillMount() { + PostEditStore.on( 'rawContentChange', this.onRawContentChange ); + }, + + componentDidMount() { + this.onRawContentChange(); + }, + + componentWillUnmount() { + PostEditStore.removeListener( 'rawContentChange', this.onRawContentChange ); + }, + + onRawContentChange() { + this.setState( { + rawContent: PostEditStore.getRawContent() + } ); + }, + + render() { + const currentUser = user.get(); + const localeSlug = currentUser && currentUser.localeSlug || 'en'; + + switch ( localeSlug ) { + case 'ja': + case 'th': + case 'zh-cn': + case 'zh-hk': + case 'zh-sg': + case 'zh-tw': + // TODO these are character-based languages - count characters instead + return null; + + case 'ko': + // TODO Korean is not supported by our current word count regex + return null; + } + + return ( +
+ { this.translate( 'Word Count' ) } + +
+ ); + }, + + getCount() { + return textUtils.countWords( this.state.rawContent ); + } +} ); diff --git a/client/post-editor/post-editor.jsx b/client/post-editor/post-editor.jsx index 15148b06d76e5b..a9dc7d53660401 100644 --- a/client/post-editor/post-editor.jsx +++ b/client/post-editor/post-editor.jsx @@ -28,6 +28,7 @@ var actions = require( 'lib/posts/actions' ), SimpleNotice = require( 'notices/simple-notice' ), protectForm = require( 'lib/mixins/protect-form' ), TinyMCE = require( 'components/tinymce' ), + EditorWordCount = require( 'post-editor/editor-word-count' ), SegmentedControl = require( 'components/segmented-control' ), SegmentedControlItem = require( 'components/segmented-control/item' ), EditorMobileNavigation = require( 'post-editor/editor-mobile-navigation' ), @@ -390,6 +391,9 @@ var PostEditor = React.createClass( { onTextEditorChange={ this.onEditorContentChange } onTogglePin={ this.onTogglePin } /> +
+ +
{ this.iframePreviewEnabled() ?