Skip to content
This repository was archived by the owner on Nov 28, 2022. It is now read-only.

Commit 09a8c7f

Browse files
committed
✨ Koenig - Added reading time and word count display
refs TryGhost/Ghost#9724 - add `registerComponent` hook to cards so that `{{koenig-editor}}` can fetch properties from card components directly - add word count and reading time utilities - add throttled word count update routine to `{{koenig-editor}}` that walks all sections and counts text words or fetches word/image counts from card components - add `wordCountDidChange` hook to `{{koenig-editor}}` so that word count + reading time can be exposed - modify editor controller to update it's own word count property when koenig triggers it's action - modified the editor template to show reading time + word count next to the post status
1 parent 3b61ca4 commit 09a8c7f

14 files changed

+203
-12
lines changed

app/components/gh-koenig-editor.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default Component.extend({
2020
onTitleBlur() {},
2121
onBodyChange() {},
2222
onEditorCreated() {},
23+
onWordCountChange() {},
2324

2425
actions: {
2526
focusTitle() {

app/controllers/editor.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export default Controller.extend({
103103
useKoenig: false,
104104

105105
// koenig related properties
106-
wordcount: 0,
106+
wordcount: null,
107107

108108
/* private properties ----------------------------------------------------*/
109109

@@ -270,11 +270,6 @@ export default Controller.extend({
270270
this.toggleProperty('showReAuthenticateModal');
271271
},
272272

273-
// TODO: this should be part of the koenig component
274-
setWordcount(count) {
275-
this.set('wordcount', count);
276-
},
277-
278273
setKoenigEditor(koenig) {
279274
this._koenig = koenig;
280275

@@ -285,6 +280,10 @@ export default Controller.extend({
285280
if (this.post.isDraft) {
286281
this._koenig.cleanup();
287282
}
283+
},
284+
285+
updateWordCount(counts) {
286+
this.set('wordCount', counts);
288287
}
289288
},
290289

@@ -659,6 +658,7 @@ export default Controller.extend({
659658
this.set('shouldFocusEditor', false);
660659
this.set('leaveEditorTransition', null);
661660
this.set('infoMessage', null);
661+
this.set('wordCount', null);
662662

663663
// remove the onbeforeunload handler as it's only relevant whilst on
664664
// the editor route

app/templates/components/gh-koenig-editor.hbs

+1
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@
2929
scrollContainerSelector=scrollContainerSelector
3030
scrollOffsetTopSelector=scrollOffsetTopSelector
3131
scrollOffsetBottomSelector=scrollOffsetBottomSelector
32+
wordCountDidChange=(action onWordCountChange)
3233
}}
3334
</div>

app/templates/editor.hbs

+13-5
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,18 @@
77
}}
88
<header class="gh-editor-header br2 pe-none {{editor.headerClass}} {{if infoMessage "bg-white-90"}}">
99
<div class="flex items-center br2 h9 pa2 pl4 pr4 pe-auto {{unless infoMessage "bg-white-90"}}">
10-
<div class="gh-editor-status">
11-
{{gh-editor-post-status
12-
post=post
13-
isSaving=(or autosave.isRunning saveTasks.isRunning)
14-
}}
10+
<div class="flex items-baseline">
11+
<span class="fw4 darkgrey-l2 pl4">
12+
{{gh-editor-post-status
13+
post=post
14+
isSaving=(or autosave.isRunning saveTasks.isRunning)
15+
}}
16+
</span>
17+
{{#if wordCount}}
18+
<span class="pl3 f-small midgrey">
19+
{{wordCount.readingTime}} ({{pluralize wordCount.wordCount "word"}})
20+
</span>
21+
{{/if}}
1522
</div>
1623
{{#gh-scheduled-post-countdown post=post as |post countdown|}}
1724
<time datetime="{{post.publishedAtUTC}}" class="green f8 nudge-bottom--1 ml3" data-test-schedule-countdown>
@@ -75,6 +82,7 @@
7582
scrollOffsetTopSelector=".gh-editor-header-small"
7683
scrollOffsetBottomSelector=".gh-mobile-nav-bar"
7784
onEditorCreated=(action "setKoenigEditor")
85+
onWordCountChange=(action "updateWordCount")
7886
}}
7987

8088
{{else}}

lib/koenig-editor/addon/components/koenig-card-code.js

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Component from '@ember/component';
22
import Ember from 'ember';
3+
import countWords from '../utils/count-words';
34
import layout from '../templates/components/koenig-card-code';
45
import {computed} from '@ember/object';
56
import {htmlSafe} from '@ember/string';
@@ -24,6 +25,11 @@ export default Component.extend({
2425
selectCard() {},
2526
deselectCard() {},
2627
deleteCard() {},
28+
registerComponent() {},
29+
30+
counts: computed('payload.code', function () {
31+
return {wordCount: countWords(this.payload.code)};
32+
}),
2733

2834
toolbar: computed('isEditing', function () {
2935
if (!this.isEditing) {
@@ -55,6 +61,8 @@ export default Component.extend({
5561
}
5662

5763
this.set('payload', payload);
64+
65+
this.registerComponent(this);
5866
},
5967

6068
actions: {

lib/koenig-editor/addon/components/koenig-card-embed.js

+12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import Component from '@ember/component';
2+
import countWords from '../utils/count-words';
23
import layout from '../templates/components/koenig-card-embed';
34
import noframe from 'noframe.js';
45
import {NO_CURSOR_MOVEMENT} from './koenig-editor';
6+
import {computed} from '@ember/object';
57
import {isBlank} from '@ember/utils';
68
import {run} from '@ember/runloop';
79
import {inject as service} from '@ember/service';
@@ -31,12 +33,22 @@ export default Component.extend({
3133
moveCursorToNextSection() {},
3234
moveCursorToPrevSection() {},
3335
addParagraphAfterCard() {},
36+
registerComponent() {},
37+
38+
counts: computed('payload.{html,caption}', function () {
39+
return {
40+
imageCount: this.payload.html ? 1 : 0,
41+
wordCount: countWords(this.payload.caption)
42+
};
43+
}),
3444

3545
init() {
3646
this._super(...arguments);
3747
if (this.payload.url && !this.payload.html) {
3848
this.convertUrl.perform(this.payload.url);
3949
}
50+
51+
this.registerComponent(this);
4052
},
4153

4254
didInsertElement() {

lib/koenig-editor/addon/components/koenig-card-hr.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,11 @@ export default Component.extend({
77

88
// closure actions
99
selectCard() {},
10-
deselectCard() {}
10+
deselectCard() {},
11+
registerComponent() {},
12+
13+
init() {
14+
this._super(...arguments);
15+
this.registerComponent(this);
16+
}
1117
});

lib/koenig-editor/addon/components/koenig-card-html.js

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Component from '@ember/component';
2+
import countWords, {countImages, stripTags} from '../utils/count-words';
23
import layout from '../templates/components/koenig-card-html';
34
import {computed} from '@ember/object';
45
import {isBlank} from '@ember/utils';
@@ -20,6 +21,14 @@ export default Component.extend({
2021
editCard() {},
2122
saveCard() {},
2223
deleteCard() {},
24+
registerComponent() {},
25+
26+
counts: computed('payload.html', function () {
27+
return {
28+
wordCount: countWords(stripTags(this.payload.html)),
29+
imageCount: countImages(this.payload.html)
30+
};
31+
}),
2332

2433
toolbar: computed('isEditing', function () {
2534
if (!this.isEditing) {
@@ -46,6 +55,8 @@ export default Component.extend({
4655
}
4756

4857
this.set('payload', payload);
58+
59+
this.registerComponent(this);
4960
},
5061

5162
actions: {

lib/koenig-editor/addon/components/koenig-card-image.js

+19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import $ from 'jquery';
22
import Component from '@ember/component';
3+
import countWords from '../utils/count-words';
34
import layout from '../templates/components/koenig-card-image';
45
import {
56
IMAGE_EXTENSIONS,
@@ -34,6 +35,22 @@ export default Component.extend({
3435
moveCursorToNextSection() {},
3536
moveCursorToPrevSection() {},
3637
addParagraphAfterCard() {},
38+
registerComponent() {},
39+
40+
counts: computed('payload.{src,caption}', function () {
41+
let wordCount = 0;
42+
let imageCount = 0;
43+
44+
if (this.payload.src) {
45+
imageCount += 1;
46+
}
47+
48+
if (this.payload.caption) {
49+
wordCount += countWords(this.payload.caption);
50+
}
51+
52+
return {wordCount, imageCount};
53+
}),
3754

3855
kgImgStyle: computed('payload.imageStyle', function () {
3956
let imageStyle = this.payload.imageStyle;
@@ -96,6 +113,8 @@ export default Component.extend({
96113
if (!this.payload) {
97114
this.set('payload', {});
98115
}
116+
117+
this.registerComponent(this);
99118
},
100119

101120
didReceiveAttrs() {

lib/koenig-editor/addon/components/koenig-card-markdown.js

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Component from '@ember/component';
2+
import countWords, {countImages, stripTags} from '../utils/count-words';
23
import formatMarkdown from 'ghost-admin/utils/format-markdown';
34
import layout from '../templates/components/koenig-card-markdown';
45
import {computed} from '@ember/object';
@@ -29,6 +30,14 @@ export default Component.extend({
2930
selectCard() {},
3031
deselectCard() {},
3132
deleteCard() {},
33+
registerComponent() {},
34+
35+
counts: computed('renderedMarkdown', function () {
36+
return {
37+
wordCount: countWords(stripTags(this.renderedMarkdown)),
38+
imageCount: countImages(this.renderedMarkdown)
39+
};
40+
}),
3241

3342
renderedMarkdown: computed('payload.markdown', function () {
3443
return htmlSafe(formatMarkdown(this.payload.markdown));
@@ -59,6 +68,8 @@ export default Component.extend({
5968
// subtract toolbar height from MIN_HEIGHT so the trigger happens at
6069
// the expected position without forcing the min height to be too small
6170
this.set('bottomOffset', -MIN_HEIGHT - 49);
71+
72+
this.registerComponent(this);
6273
},
6374

6475
willDestroyElement() {

lib/koenig-editor/addon/components/koenig-editor.js

+41
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import Editor from 'mobiledoc-kit/editor/editor';
88
import EmberObject, {computed, get} from '@ember/object';
99
import Key from 'mobiledoc-kit/utils/key';
1010
import MobiledocRange from 'mobiledoc-kit/utils/cursor/range';
11+
import calculateReadingTime from '../utils/reading-time';
12+
import countWords from '../utils/count-words';
1113
import defaultAtoms from '../options/atoms';
1214
import defaultCards from '../options/cards';
1315
import formatMarkdown from 'ghost-admin/utils/format-markdown';
@@ -197,6 +199,7 @@ export default Component.extend({
197199
didCreateEditor() {},
198200
onChange() {},
199201
cursorDidExitAtTop() {},
202+
wordCountDidChange() {},
200203

201204
/* computed properties -------------------------------------------------- */
202205

@@ -405,6 +408,8 @@ export default Component.extend({
405408

406409
this.set('editor', editor);
407410
this.didCreateEditor(this);
411+
412+
run.schedule('afterRender', this, this._calculateWordCount);
408413
},
409414

410415
didInsertElement() {
@@ -634,6 +639,9 @@ export default Component.extend({
634639

635640
// trigger closure action
636641
this.onChange(updatedMobiledoc);
642+
643+
// re-calculate word count
644+
this._calculateWordCount();
637645
},
638646

639647
cursorDidChange(editor) {
@@ -1133,6 +1141,39 @@ export default Component.extend({
11331141
}
11341142
},
11351143

1144+
// calculate the number of words in rich-text sections and query cards for
1145+
// their own word and image counts. Image counts are used for reading-time
1146+
_calculateWordCount() {
1147+
run.throttle(this, this._throttledWordCount, 100, false);
1148+
},
1149+
1150+
_throttledWordCount() {
1151+
let wordCount = 0;
1152+
let imageCount = 0;
1153+
1154+
this.editor.post.walkAllLeafSections((section) => {
1155+
if (section.isCardSection) {
1156+
// get counts from card components
1157+
let card = this.getCardFromSection(section);
1158+
let cardCounts = get(card, 'component.counts') || {};
1159+
wordCount += cardCounts.wordCount || 0;
1160+
imageCount += cardCounts.imageCount || 0;
1161+
} else {
1162+
wordCount += countWords(section.text);
1163+
}
1164+
});
1165+
1166+
if (wordCount !== this.wordCount || imageCount !== this.imageCount) {
1167+
let readingTime = calculateReadingTime({wordCount, imageCount});
1168+
1169+
this.wordCount = wordCount;
1170+
this.imageCount = imageCount;
1171+
this.readingTime = readingTime;
1172+
1173+
this.wordCountDidChange({wordCount, imageCount, readingTime});
1174+
}
1175+
},
1176+
11361177
// store a reference to the editor for the acceptance test helpers
11371178
_setExpandoProperty(editor) {
11381179
let config = getOwner(this).resolveRegistration('config:environment');

lib/koenig-editor/addon/templates/components/koenig-editor.hbs

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
moveCursorToPrevSection=(action "moveCursorToPrevSection" card)
7575
moveCursorToNextSection=(action "moveCursorToNextSection" card)
7676
addParagraphAfterCard=(action "addParagraphAfterCard" card)
77+
registerComponent=(action (mut card.component))
7778
}}
7879
{{/-in-element}}
7980
{{/each}}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Word count Utility
3+
* @param {string} html
4+
* @returns {integer} word count
5+
* @description Takes a string and returns the number of words
6+
* This code is taken from https://github.com/sparksuite/simplemde-markdown-editor/blob/6abda7ab68cc20f4aca870eb243747951b90ab04/src/js/simplemde.js#L1054-L1067
7+
* with extra diacritics character matching. It's the same code as used in
8+
* Ghost's {{reading_time}} helper
9+
**/
10+
export default function countWords(text = '') {
11+
// protect against Handlebars.SafeString
12+
if (text.hasOwnProperty('string')) {
13+
text = text.string;
14+
}
15+
16+
let pattern = /[a-zA-ZÀ-ÿ0-9_\u0392-\u03c9\u0410-\u04F9]+|[\u4E00-\u9FFF\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af]+/g;
17+
let match = text.match(pattern);
18+
let count = 0;
19+
20+
if (match === null) {
21+
return count;
22+
}
23+
24+
for (var i = 0; i < match.length; i += 1) {
25+
if (match[i].charCodeAt(0) >= 0x4E00) {
26+
count += match[i].length;
27+
} else {
28+
count += 1;
29+
}
30+
}
31+
32+
return count;
33+
}
34+
35+
export function countImages(html = '') {
36+
// protect against Handlebars.SafeString
37+
if (html.hasOwnProperty('string')) {
38+
html = html.string;
39+
}
40+
41+
return (html.match(/<img(.|\n)*?>/g) || []).length;
42+
}
43+
44+
export function stripTags(html = '') {
45+
// protect against Handlebars.SafeString
46+
if (html.hasOwnProperty('string')) {
47+
html = html.string;
48+
}
49+
50+
return html.replace(/<(.|\n)*?>/g, ' ');
51+
}

0 commit comments

Comments
 (0)