🔢 Add wordcount feature. (#668)

closes TryGhost/Ghost#8202
-  added wordcount for mobiledoc text and html/markdown cards (cards will only update word count when leaving edit state)
- word count is only displayed on wide screens
This commit is contained in:
Ryan McCarvill 2017-04-25 23:32:27 +12:00 committed by Kevin Ansfield
parent c6ed77a0fc
commit e604b255af
8 changed files with 100 additions and 5 deletions

View File

@ -42,6 +42,7 @@ export default Mixin.create({
clock: injectService(), clock: injectService(),
slugGenerator: injectService(), slugGenerator: injectService(),
wordcount: 0,
cards: [], // for apps cards: [], // for apps
atoms: [], // for apps atoms: [], // for apps
toolbar: [], // for apps toolbar: [], // for apps
@ -580,6 +581,10 @@ export default Mixin.create({
editorMenuIsClosed() { editorMenuIsClosed() {
this.set('editorMenuIsOpen', false); this.set('editorMenuIsOpen', false);
},
wordcountDidChange(wordcount) {
this.set('wordcount', wordcount);
} }
} }
}); });

View File

@ -251,7 +251,18 @@
line-height: 0; line-height: 0;
} }
.gh-editor-header-small .post-settings { .gh-editor-header-small .post-settings {
padding: 13px 15px; padding: 13px 15px;
} }
.gh-editor-wordcount {
position: fixed;
bottom: 0px;
padding:10px;
}
@media (max-width: 1200px) {
.gh-editor-wordcount {
display: none;
}
}

View File

@ -50,9 +50,11 @@
setEditor=(action "setEditor") setEditor=(action "setEditor")
menuIsOpen=(action "editorMenuIsOpen") menuIsOpen=(action "editorMenuIsOpen")
menuIsClosed=(action "editorMenuIsClosed") menuIsClosed=(action "editorMenuIsClosed")
wordcountDidChange=(action "wordcountDidChange")
}} }}
</div> </div>
</div> </div>
<div class="gh-editor-wordcount">{{pluralize wordcount 'word'}}.</div>
{{/gh-editor}} {{/gh-editor}}
{{#if showDeletePostModal}} {{#if showDeletePostModal}}

View File

@ -3,13 +3,16 @@ import layout from '../../templates/components/card-html';
import computed from 'ember-computed'; import computed from 'ember-computed';
import observer from 'ember-metal/observer'; import observer from 'ember-metal/observer';
import {invokeAction} from 'ember-invoke-action'; import {invokeAction} from 'ember-invoke-action';
import counter from 'ghost-admin/utils/word-count';
export default Component.extend({ export default Component.extend({
layout, layout,
hasRendered: false, hasRendered: false,
save: observer('doSave', function () { save: observer('doSave', function () {
this.get('env').save(this.get('payload'), false); let payload = this.get('payload');
payload.wordcount = counter(payload.html);
this.get('env').save(payload, false);
}), }),
value: computed('payload', { value: computed('payload', {

View File

@ -8,6 +8,7 @@ import {isBlank} from 'ember-utils';
import computed from 'ember-computed'; import computed from 'ember-computed';
import observer from 'ember-metal/observer'; import observer from 'ember-metal/observer';
import run from 'ember-runloop'; import run from 'ember-runloop';
import counter from 'ghost-admin/utils/word-count';
import { import {
isRequestEntityTooLargeError, isRequestEntityTooLargeError,
isUnsupportedMediaTypeError, isUnsupportedMediaTypeError,
@ -29,6 +30,7 @@ export default Component.extend({
save: observer('doSave', function () { save: observer('doSave', function () {
let payload = this.get('payload'); let payload = this.get('payload');
payload.markdown = this.$('textarea').val(); payload.markdown = this.$('textarea').val();
payload.wordcount = counter(payload.markdown);
this.set('value', this.$('textarea').val()); this.set('value', this.$('textarea').val());
this.set('payload', payload); this.set('payload', payload);
this.get('env').save(payload, false); this.get('env').save(payload, false);

View File

@ -8,8 +8,8 @@ import createCardFactory from '../lib/card-factory';
import defaultCommands from '../options/default-commands'; import defaultCommands from '../options/default-commands';
import editorCards from '../cards/index'; import editorCards from '../cards/index';
import {getCardFromDoc, checkIfClickEventShouldCloseCard, getPositionOnScreenFromRange} from '../lib/utils'; import {getCardFromDoc, checkIfClickEventShouldCloseCard, getPositionOnScreenFromRange} from '../lib/utils';
import counter from 'ghost-admin/utils/word-count';
import $ from 'jquery'; import $ from 'jquery';
// import { VALID_MARKUP_SECTION_TAGNAMES } from 'mobiledoc-kit/models/markup-section'; //the block elements supported by mobile-doc
export const BLANK_DOC = { export const BLANK_DOC = {
version: MOBILEDOC_VERSION, version: MOBILEDOC_VERSION,
@ -103,6 +103,7 @@ export default Component.extend({
this._firstChange = true; this._firstChange = true;
this.sendAction('onFirstChange', this._cachedDoc); this.sendAction('onFirstChange', this._cachedDoc);
} }
this.processWordcount();
}); });
}); });
}, },
@ -151,6 +152,7 @@ export default Component.extend({
} }
editor.cursorDidChange(() => this.cursorMoved()); editor.cursorDidChange(() => this.cursorMoved());
this.processWordcount();
}, },
// makes sure the cursor is on screen except when selection is happening in which case the browser mostly ensures it. // makes sure the cursor is on screen except when selection is happening in which case the browser mostly ensures it.
@ -193,7 +195,21 @@ export default Component.extend({
this.send('deselectCard'); this.send('deselectCard');
} }
}, },
// Note: This wordcount function doesn't count words that have been entered in cards.
// We should either allow cards to report their own wordcount or use the DOM (innerText) to calculate the wordcount.
processWordcount() {
let wordcount = 0;
if (this.editor.post.sections.length) {
this.editor.post.sections.forEach((section) => {
if (section.isMarkerable && section.text.length) {
wordcount += counter(section.text);
} else if (section.isCardSection && section.payload.wordcount) {
wordcount += Number(section.payload.wordcount);
}
});
}
this.sendAction('wordcountDidChange', wordcount);
},
willDestroy() { willDestroy() {
this.editor.destroy(); this.editor.destroy();
this.send('deselectCard'); this.send('deselectCard');

View File

@ -17,7 +17,7 @@
{{/ember-wormhole}} {{/ember-wormhole}}
{{/each}} {{/each}}
<div class='gh-koenig'> <div class='gh-koenig'>
<div class='surface needsclick' tabindex="{{tabindex}}" ondrop={{action "dropImage"}} ondragover={{action "dragOver"}} /> <div class='surface' tabindex="{{tabindex}}" ondrop={{action "dropImage"}} ondragover={{action "dragOver"}} />
</div> </div>
{{yield}} {{yield}}

View File

@ -0,0 +1,56 @@
/* jshint expr:true */
import {describe, it} from 'mocha';
import {setupComponentTest} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import {editorRendered, testInput} from '../../helpers/editor-helpers';
import sinon from 'sinon';
describe.skip('Integration: Component: gh-koenig - General Editor Tests.', function () {
setupComponentTest('gh-koenig', {
integration: true
});
beforeEach(function () {
// set defaults
this.set('onFirstChange', sinon.spy());
this.set('onChange', sinon.spy());
this.set('wordcount', 0);
this.set('actions.wordcountDidChange', function (wordcount) {
this.set('wordcount', wordcount);
});
this.set('value', {
version: '0.3.1',
atoms: [],
markups: [],
cards: [],
sections: []});
});
it('Check that events have fired', function (done) {
this.render(hbs`{{gh-koenig
apiRoot='/todo'
assetPath='/assets'
containerSelector='.editor-holder'
value=value
onChange=(action onChange)
onFirstChange=(action onFirstChange)
wordcountDidChange=(action 'wordcountDidChange')
}}`);
editorRendered()
.then(() => {
let {editor} = window;
editor.element.focus();
return testInput('abcd efg hijk lmnop', '<p>abcd efg hijk lmnop</p>', expect);
})
.then(() => {
expect(this.get('onFirstChange').calledOnce).to.be.true;
expect(this.get('onChange').calledOnce).to.be.true;
expect(this.get('wordcount')).to.equal(4);
done();
});
});
});