├── .babelrc
├── .eslintrc
├── .gitignore
├── README.md
├── Squirt.js
├── lib
├── main.es6
├── squirt-controls.component.es6
├── squirt-node.component.es6
├── squirt-parser.es6
├── squirt-play-pause-toggle.component.es6
├── squirt-preferences.component.es6
├── squirt-run-time.component.es6
├── squirt-scrubber.component.es6
├── squirt-settings.component.es6
├── squirt-wpm-control.component.es6
└── squirt.store.es6
├── package.json
├── resources
└── loremipsum.html
├── spec
├── squirt-parser.spec.es6
└── squirt.strore.spec.es6
├── squirt-icon.png
└── stylesheets
└── main.less
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "sourceMaps": "inline",
3 | "presets": ["es2015", "react"],
4 | "plugins": ["transform-class-properties", "transform-es2015-modules-commonjs"]
5 | }
6 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "globals": {
4 | },
5 | "env": {
6 | "browser": true,
7 | "node": true,
8 | "jasmine": true
9 | },
10 | "ecmaFeatures": {
11 | "jsx": true,
12 | "classes": true
13 | },
14 | "rules": {
15 | "react/prop-types": [2, {"ignore": ["children"]}],
16 | "eqeqeq": [2, "smart"],
17 | "id-length": [0],
18 | "no-loop-func": [0],
19 | "new-cap": [2, {"capIsNew": false}]
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Speed Reader for Long Messages
3 |
4 | ## Install
5 |
6 | - Click on the "Clone and Download Button"
7 | - Click on the Download ZIP option
8 | - Extract the ZIP file
9 | - Go to preferences in N1
10 | - Click on the "Plugins" tab
11 | - Click "Install Plugin"
12 | - Navigate to your downloads folder and select the "squirt-reader-N1-plugin-master" file and click "open"
13 |
14 | - *Optional:* set your defaults in the Squirt Preferences tab that should appear post install. This will set a minimum length of the e-mails that the Squirt reader will show up.
15 |
16 | ## A lot TODO:
17 | - [x] Parse e-mail html to string
18 | - [x] build into nodes with ORP
19 | - [x] display nodes
20 | - [x] SquirtStore to handle state and events for the reader
21 | - [x] Auto play at hard coded wpm
22 | - [x] Styling for centered ORP letter
23 | - [ ] tests for SquirtStore methods
24 | - [x] control component for play/pause
25 | - [x] control component for jump forwards and backwards
26 | - [ ] key bindings for play pause jump etc
27 | - [x] save settings in serialize and deserialize methods
28 | - [ ] transition reader in after hitting play
29 |
--------------------------------------------------------------------------------
/Squirt.js:
--------------------------------------------------------------------------------
1 | var sq = window.sq;
2 | sq.version = '0.0.1';
3 | sq.host = window.location.search.match('sq-dev') ?
4 | document.scripts[document.scripts.length - 1].src.match(/\/\/.*\//)[0]
5 | : '//www.squirt.io/bm/';
6 |
7 | (function (Keen) {
8 | Keen.addEvent('load');
9 |
10 | on('mousemove', function () {
11 | document.querySelector('.sq .modal').style.cursor = 'auto';
12 | });
13 |
14 | (function makeSquirt(read, makeGUI) {
15 |
16 | on('squirt.again', startSquirt);
17 | injectStylesheet(sq.host + 'font-awesome.css');
18 | injectStylesheet(sq.host + 'squirt.css', function stylesLoaded() {
19 | makeGUI();
20 | startSquirt();
21 | });
22 |
23 | function startSquirt() {
24 | Keen.addEvent('start');
25 | showGUI();
26 | getText(read);
27 | }
28 |
29 | function getText(read) {
30 | // text source: demo
31 | if (window.squirtText) return read(window.squirtText);
32 |
33 | // text source: selection
34 | var selection = window.getSelection();
35 | if (selection.type === 'Range') {
36 | var container = document.createElement('div');
37 | for (var i = 0, len = selection.rangeCount; i < len; ++i) {
38 | container.appendChild(selection.getRangeAt(i).cloneContents());
39 | }
40 | return read(container.textContent);
41 | }
42 |
43 | // text source: readability
44 | var handler;
45 | function readabilityReady() {
46 | handler && document.removeEventListener('readility.ready', handler);
47 | read(readability.grabArticleText());
48 | }
49 |
50 | if (window.readability) return readabilityReady();
51 |
52 | makeEl('script', {
53 | src: sq.host + 'readability.js'
54 | }, document.head);
55 | handler = on('readability.ready', readabilityReady);
56 | }
57 | })(makeRead(makeTextToNodes(wordToNode)), makeGUI);
58 |
59 | function makeRead(textToNodes) {
60 | sq.paused = false;
61 | var nodeIdx,
62 | nodes,
63 | lastNode,
64 | nextNodeTimeoutId;
65 |
66 | function incrememntNodeIdx(increment) {
67 | var ret = nodeIdx;
68 | nodeIdx += increment || 1;
69 | nodeIdx = Math.max(0, nodeIdx);
70 | prerender();
71 | return ret;
72 | }
73 |
74 | var intervalMs, _wpm;
75 | function wpm(wpm) {
76 | _wpm = wpm;
77 | intervalMs = 60 * 1000 / wpm ;
78 | }
79 |
80 | (function readerEventHandlers() {
81 | on('squirt.close', function () {
82 | sq.closed = true;
83 | clearTimeout(nextNodeTimeoutId);
84 | Keen.addEvent('close');
85 | });
86 |
87 | on('squirt.wpm.adjust', function (e) {
88 | dispatch('squirt.wpm', { value: e.value + _wpm });
89 | });
90 |
91 | on('squirt.wpm', function (e) {
92 | sq.wpm = Number(e.value);
93 | wpm(e.value);
94 | dispatch('squirt.wpm.after');
95 | e.notForKeen === undefined && Keen.addEvent('wpm', { 'wpm': sq.wpm });
96 | });
97 |
98 | on('squirt.pause', pause);
99 | on('squirt.play', play);
100 |
101 | on('squirt.play.toggle', function () {
102 | dispatch(sq.paused ? 'squirt.play' : 'squirt.pause');
103 | });
104 |
105 | on('squirt.rewind', function (e) {
106 | // Rewind by `e.value` seconds. Then walk back to the
107 | // beginning of the sentence.
108 | !sq.paused && clearTimeout(nextNodeTimeoutId);
109 | incrememntNodeIdx(-Math.floor(e.seconds * 1000 / intervalMs));
110 | while (!nodes[nodeIdx].word.match(/\./) && nodeIdx < 0) {
111 | incrememntNodeIdx(-1);
112 | }
113 | nextNode(true);
114 | Keen.addEvent('rewind');
115 | });
116 | })();
117 |
118 | function pause() {
119 | sq.paused = true;
120 | dispatch('squirt.pause.after');
121 | clearTimeout(nextNodeTimeoutId);
122 | Keen.addEvent('pause');
123 | }
124 |
125 | function play(e) {
126 | sq.paused = false;
127 | dispatch('squirt.pause.after');
128 | document.querySelector('.sq .wpm-selector').style.display = 'none';
129 | nextNode(e.jumped);
130 | e.notForKeen === undefined && Keen.addEvent('play');
131 | }
132 |
133 | var toRender;
134 | function prerender() {
135 | toRender = nodes[nodeIdx];
136 | if (toRender == null) return;
137 | prerenderer.appendChild(toRender);
138 | nodes[nodeIdx].center();
139 | }
140 |
141 | function finalWord() {
142 | Keen.addEvent('final-word');
143 | toggle(document.querySelector('.sq .reader'));
144 | if (window.location.hostname.match('squirt.io|localhost')) {
145 | window.location.href = '/install.html';
146 | } else {
147 | showTweetButton(nodes.length,
148 | (nodes.length * intervalMs / 1000 / 60).toFixed(1));
149 | }
150 | toggle(finalWordContainer);
151 | return;
152 | }
153 |
154 | var delay, jumped, nextIdx;
155 | function nextNode(jumped) {
156 | lastNode && lastNode.remove();
157 |
158 | nextIdx = incrememntNodeIdx();
159 | if (nextIdx >= nodes.length) return finalWord();
160 |
161 | lastNode = nodes[nextIdx];
162 | wordContainer.appendChild(lastNode);
163 | lastNode.instructions && invoke(lastNode.instructions);
164 | if (sq.paused) return;
165 | nextNodeTimeoutId = setTimeout(nextNode, intervalMs * getDelay(lastNode, jumped));
166 | }
167 |
168 | var waitAfterShortWord = 1.2;
169 | var waitAfterComma = 2;
170 | var waitAfterPeriod = 3;
171 | var waitAfterParagraph = 3.5;
172 | var waitAfterLongWord = 1.5;
173 | function getDelay(node, jumped) {
174 | var word = node.word;
175 | if (jumped) return waitAfterPeriod;
176 | if (word === 'Mr.' ||
177 | word === 'Mrs.' ||
178 | word === 'Ms.') return 1;
179 | var lastChar = word[word.length - 1];
180 | if (lastChar.match('”|"')) lastChar = word[word.length - 2];
181 | if (lastChar === '\n') return waitAfterParagraph;
182 | if ('.!?'.indexOf(lastChar) !== -1) return waitAfterPeriod;
183 | if (',;:–'.indexOf(lastChar) !== -1) return waitAfterComma;
184 | if (word.length < 4) return waitAfterShortWord;
185 | if (word.length > 11) return waitAfterLongWord;
186 | return 1;
187 | }
188 |
189 | function showTweetButton(words, minutes) {
190 | var html = '
You just read ' + words + ' words in ' + minutes + ' minutes!
';
191 | var tweetString = 'I read ' + words + ' words in ' + minutes + ' minutes without breaking a sweat—www.squirt.io turns your browser into a speed reading machine!';
192 | var paramStr = encodeURI('url=squirt.io&user=squirtio&size=large&text=' +
193 | tweetString);
194 | html += '';
200 | finalWordContainer.innerHTML = html;
201 | }
202 |
203 | function showInstallLink() {
204 | finalWordContainer.innerHTML = "Install Squirt";
205 | }
206 |
207 | function readabilityFail() {
208 | Keen.addEvent('readability-fail');
209 | var modal = document.querySelector('.sq .modal');
210 | modal.innerHTML = 'Oops! This page is too hard for Squirt to read. We\'ve been notified, and will do our best to resolve the issue shortly.
';
211 | }
212 |
213 | dispatch('squirt.wpm', { value: 400, notForKeen: true });
214 |
215 | var wordContainer,
216 | prerenderer,
217 | finalWordContainer;
218 | function initDomRefs() {
219 | wordContainer = document.querySelector('.sq .word-container');
220 | invoke(wordContainer.querySelectorAll('.sq .word'), 'remove');
221 | prerenderer = document.querySelector('.sq .word-prerenderer');
222 | finalWordContainer = document.querySelector('.sq .final-word');
223 | document.querySelector('.sq .reader').style.display = 'block';
224 | document.querySelector('.sq .final-word').style.display = 'none';
225 | }
226 |
227 | return function read(text) {
228 | initDomRefs();
229 | if (!text) return readabilityFail();
230 |
231 | nodes = textToNodes(text);
232 | nodeIdx = 0;
233 |
234 | prerender();
235 | dispatch('squirt.play');
236 | };
237 | }
238 |
239 | function makeTextToNodes(wordToNode) {
240 | return function textToNodes(text) {
241 | text = '3\n 2\n 1\n ' + text.trim('\n').replace(/\s+\n/g, '\n');
242 | return text
243 | .replace(/[\,\.\!\:\;](?![\"\'\)\]\}])/g, '$& ')
244 | .split(/[\s]+/g)
245 | .filter(function (word) { return word.length; })
246 | .map(wordToNode);
247 | };
248 | }
249 |
250 | var instructionsRE = /#SQ(.*)SQ#/;
251 | function parseSQInstructionsForWord(word, node) {
252 | var match = word.match(instructionsRE);
253 | if (match && match.length > 1) {
254 | node.instructions = [];
255 | match[1].split('#')
256 | .filter(function (w) { return w.length; })
257 | .map(function (instruction) {
258 | var val = Number(instruction.split('=')[1]);
259 | node.instructions.push(function () {
260 | dispatch('squirt.wpm', { value: val, notForKeen: true });
261 | });
262 | });
263 | return word.replace(instructionsRE, '');
264 | }
265 | return word;
266 | }
267 |
268 | // ORP: Optimal Recgonition Point
269 | function getORPIndex(word) {
270 | var length = word.length;
271 | var lastChar = word[word.length - 1];
272 | if (lastChar === '\n') {
273 | lastChar = word[word.length - 2];
274 | length--;
275 | }
276 | if (',.?!:;"'.indexOf(lastChar) !== -1) length--;
277 | return length <= 1 ? 0 :
278 | (length === 2 ? 1 :
279 | (length === 3 ? 1 :
280 | Math.floor(length / 2) - 1));
281 | }
282 |
283 | function wordToNode(word) {
284 | var node = makeDiv({ 'class': 'word' });
285 | node.word = parseSQInstructionsForWord(word, node);
286 |
287 | var orpIdx = getORPIndex(node.word);
288 |
289 | node.word.split('').map(function charToNode(char, idx) {
290 | var span = makeEl('span', {}, node);
291 | span.textContent = char;
292 | if (idx === orpIdx) span.classList.add('orp');
293 | });
294 |
295 | node.center = (function (orpNode) {
296 | var val = orpNode.offsetLeft + (orpNode.offsetWidth / 2);
297 | node.style.left = '-' + val + 'px';
298 | }).bind(null, node.children[orpIdx]);
299 |
300 | return node;
301 | }
302 |
303 | var disableKeyboardShortcuts;
304 | function showGUI() {
305 | blur();
306 | document.querySelector('.sq').style.display = 'block';
307 | disableKeyboardShortcuts = on('keydown', handleKeypress);
308 | }
309 |
310 | function hideGUI() {
311 | unblur();
312 | document.querySelector('.sq').style.display = 'none';
313 | disableKeyboardShortcuts && disableKeyboardShortcuts();
314 | }
315 |
316 | var keyHandlers = {
317 | 32: dispatch.bind(null, 'squirt.play.toggle'),
318 | 27: dispatch.bind(null, 'squirt.close'),
319 | 38: dispatch.bind(null, 'squirt.wpm.adjust', { value: 10 }),
320 | 40: dispatch.bind(null, 'squirt.wpm.adjust', { value: -10 }),
321 | 37: dispatch.bind(null, 'squirt.rewind', { seconds: 10 })
322 | };
323 |
324 | function handleKeypress(e) {
325 | var handler = keyHandlers[e.keyCode];
326 | handler && (handler(), e.preventDefault());
327 | return false;
328 | }
329 |
330 | function blur() {
331 | map(document.body.children, function (node) {
332 | if (!node.classList.contains('sq'))
333 | node.classList.add('sq-blur');
334 | });
335 | }
336 |
337 | function unblur() {
338 | map(document.body.children, function (node) {
339 | node.classList.remove('sq-blur');
340 | });
341 | }
342 |
343 | function makeGUI() {
344 | var squirt = makeDiv({ class: 'sq' }, document.body);
345 | squirt.style.display = 'none';
346 | on('squirt.close', hideGUI);
347 | var obscure = makeDiv({ class: 'sq-obscure' }, squirt);
348 | on(obscure, 'click', function () {
349 | dispatch('squirt.close');
350 | });
351 |
352 | on(window, 'orientationchange', function () {
353 | Keen.addEvent('orientation-change', { 'orientation': window.orientation });
354 | });
355 |
356 | var modal = makeDiv({ 'class': 'modal' }, squirt);
357 |
358 | var controls = makeDiv({ 'class':'controls' }, modal);
359 | var reader = makeDiv({ 'class': 'reader' }, modal);
360 | var wordContainer = makeDiv({ 'class': 'word-container' }, reader);
361 | makeDiv({ 'class': 'focus-indicator-gap' }, wordContainer);
362 | makeDiv({ 'class': 'word-prerenderer' }, wordContainer);
363 | makeDiv({ 'class': 'final-word' }, modal);
364 | var keyboard = makeDiv({ 'class': 'keyboard-shortcuts' }, reader);
365 | keyboard.innerText = 'Keys: Space, Esc, Up, Down';
366 |
367 | (function make(controls) {
368 |
369 | // this code is suffering from delirium
370 | (function makeWPMSelect() {
371 |
372 | // create the ever-present left-hand side button
373 | var control = makeDiv({ 'class': 'sq wpm sq control' }, controls);
374 | var wpmLink = makeEl('a', {}, control);
375 | bind('{{wpm}} WPM', sq, wpmLink);
376 | on('squirt.wpm.after', wpmLink.render);
377 | on(control, 'click', function () {
378 | toggle(wpmSelector) ?
379 | dispatch('squirt.pause') :
380 | dispatch('squirt.play');
381 | });
382 |
383 | // create the custom selector
384 | var wpmSelector = makeDiv({ 'class': 'sq wpm-selector' }, controls);
385 | wpmSelector.style.display = 'none';
386 | var plus50OptData = { add: 50, sign: "+" };
387 | var datas = [];
388 | for (var wpm = 200; wpm < 1000; wpm += 100) {
389 | var opt = makeDiv({ 'class': 'sq wpm-option' }, wpmSelector);
390 | var a = makeEl('a', {}, opt);
391 | a.data = { baseWPM: wpm };
392 | a.data.__proto__ = plus50OptData;
393 | datas.push(a.data);
394 | bind('{{wpm}}', a.data, a);
395 | on(opt, 'click', function (e) {
396 | dispatch('squirt.wpm', { value: e.target.firstChild.data.wpm });
397 | dispatch('squirt.play');
398 | wpmSelector.style.display = 'none';
399 | });
400 | }
401 |
402 | // create the last option for the custom selector
403 | var plus50Opt = makeDiv({ 'class': 'sq wpm-option sq wpm-plus-50' }, wpmSelector);
404 | var a = makeEl('a', {}, plus50Opt);
405 | bind('{{sign}}50', plus50OptData, a);
406 | on(plus50Opt, 'click', function () {
407 | datas.map(function (data) {
408 | data.wpm = data.baseWPM + data.add;
409 | });
410 | var toggle = plus50OptData.sign === '+';
411 | plus50OptData.sign = toggle ? '-' : '+';
412 | plus50OptData.add = toggle ? 0 : 50;
413 | dispatch('squirt.els.render');
414 | });
415 | dispatch('click', {}, plus50Opt);
416 | })();
417 |
418 | (function makeRewind() {
419 | var container = makeEl('div', { 'class': 'sq rewind sq control' }, controls);
420 | var a = makeEl('a', {}, container);
421 | a.href = '#';
422 | on(container, 'click', function (e) {
423 | dispatch('squirt.rewind', { seconds: 10 });
424 | e.preventDefault();
425 | });
426 | a.innerHTML = " 10s";
427 | })();
428 |
429 | (function makePause() {
430 | var container = makeEl('div', { 'class': 'sq pause control' }, controls);
431 | var a = makeEl('a', { 'href': '#' }, container);
432 | var pauseIcon = "";
433 | var playIcon = "";
434 | function updateIcon() {
435 | a.innerHTML = sq.paused ? playIcon : pauseIcon;
436 | }
437 | on('squirt.pause.after', updateIcon);
438 | on(container, 'click', function (clickEvt) {
439 | dispatch('squirt.play.toggle');
440 | clickEvt.preventDefault();
441 | });
442 | updateIcon();
443 | })();
444 | })(controls);
445 | }
446 |
447 | // utilites
448 |
449 | function map(listLike, f) {
450 | listLike = Array.prototype.slice.call(listLike); // for safari
451 | return Array.prototype.map.call(listLike, f);
452 | }
453 |
454 | // invoke([f1, f2]); // calls f1() and f2()
455 | // invoke([o1, o2], 'func'); // calls o1.func(), o2.func()
456 | // args are applied to both invocation patterns
457 | function invoke(objs, funcName, args) {
458 | args = args || [];
459 | var objsAreFuncs = false;
460 | switch (typeof funcName) {
461 | case 'object':
462 | args = funcName;
463 | break;
464 | case 'undefined':
465 | objsAreFuncs = true;
466 | }
467 | return map(objs, function (o) {
468 | return objsAreFuncs ? o.apply(null, args) : o[funcName].apply(o, args);
469 | });
470 | }
471 |
472 | function makeEl(type, attrs, parent) {
473 | var el = document.createElement(type);
474 | for (var k in attrs) {
475 | if (!attrs.hasOwnProperty(k)) continue;
476 | el.setAttribute(k, attrs[k]);
477 | }
478 | parent && parent.appendChild(el);
479 | return el;
480 | }
481 |
482 | // data binding... *cough*
483 | function bind(expr, data, el) {
484 | el.render = render.bind(null, expr, data, el);
485 | return on('squirt.els.render', function () {
486 | el.render();
487 | });
488 | }
489 |
490 | function render(expr, data, el) {
491 | var match, rendered = expr;
492 | expr.match(/{{[^}]+}}/g).map(function (match) {
493 | var val = data[match.substr(2, match.length - 4)];
494 | rendered = rendered.replace(match, val === undefined ? '' : val);
495 | });
496 | el.textContent = rendered;
497 | }
498 |
499 | function makeDiv(attrs, parent) {
500 | return makeEl('div', attrs, parent);
501 | }
502 |
503 | function injectStylesheet(url, onLoad) {
504 | var el = makeEl('link', {
505 | rel: 'stylesheet',
506 | href: url,
507 | type: 'text/css'
508 | }, document.head);
509 | function loadHandler() {
510 | onLoad();
511 | el.removeEventListener('load', loadHandler);
512 | }
513 | onLoad && on(el, 'load', loadHandler);
514 | }
515 |
516 |
517 | function on(bus, evts, cb) {
518 | if (cb === undefined) {
519 | cb = evts;
520 | evts = bus;
521 | bus = document;
522 | }
523 | evts = typeof evts == 'string' ? [evts] : evts;
524 | var removers = evts.map(function (evt) {
525 | bus.addEventListener(evt, cb);
526 | return function () {
527 | bus.removeEventListener(evt, cb);
528 | };
529 | });
530 | if (removers.length === 1) return removers[0];
531 | return removers;
532 | }
533 |
534 | function dispatch(evt, attrs, dispatcher) {
535 | var evt = new Event(evt);
536 | for (var k in attrs) {
537 | if (!attrs.hasOwnProperty(k)) continue;
538 | evt[k] = attrs[k];
539 | }
540 | (dispatcher || document).dispatchEvent(evt);
541 | }
542 |
543 | function toggle(el) {
544 | var s = window.getComputedStyle(el);
545 | return (el.style.display = s.display === 'none' ? 'block' : 'none') === 'block';
546 | }
547 |
548 | })((function injectKeen() {
549 | window.Keen = window.Keen || { configure: function (e) {this._cf = e;}, addEvent: function (e, t, n, i) {this._eq = this._eq || [], this._eq.push([e, t, n, i]);}, setGlobalProperties: function (e) {this._gp = e;}, onChartsReady: function (e) {this._ocrq = this._ocrq || [], this._ocrq.push(e);} };(function () {var e = document.createElement('script');e.type='text/javascript', e.async = !0, e.src = ("https:" == document.location.protocol?'https://':'http://')+'dc8na2hxrj29i.cloudfront.net/code/keen-2.1.0-min.js'; var t = document.getElementsByTagName('script')[0];t.parentNode.insertBefore(e, t);})();
550 |
551 | var Keen = window.Keen;
552 | var prod = {
553 | projectId: '531d7ffd36bf5a1ec4000000',
554 | writeKey: '9bdde746be9a9c7bca138171c98d6b7a4b4ce7f9c12dc62f0c3404ea8c7b5415a879151825b668a5682e0862374edaf46f7d6f25772f2fa6bc29aeef02310e8c376e89beffe7e3a4c5227a3aa7a40d8ce1dcde7cf28c7071b2b0e3c12f06b513c5f92fa5a9cfbc1bebaddaa7c595734d'
555 | };
556 | var dev = {
557 | projectId: '531aa8c136bf5a0f8e000003',
558 | writeKey: 'a863509cd0ba1c7039d54e977520462be277d525f29e98798ae4742b963b22ede0234c467494a263bd6d6b064413c29cd984e90e6e6a4468d36fed1b04bcfce6f19f50853e37b45cb283b4d0dfc4c6e7a9a23148b1696d7ea2624f1c907abfac23a67bbbead623522552de3fedced628'
559 | };
560 |
561 | Keen.configure(sq.host.match('squirt.io') ? prod : dev);
562 |
563 | function addon(name, input, output) {
564 | return { name: name, input: input, output: output };
565 | }
566 |
567 | function guid() {
568 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
569 | var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
570 | return v.toString(16);
571 | });
572 | }
573 |
574 | Keen.setGlobalProperties(function () {
575 | var props = {
576 | source: 'bookmarklet',
577 | userId: sq.userId || 'beta-user',
578 | href: window.location.href,
579 | rawUserAgent: '${keen.user_agent}',
580 | sessionId: 'sq-sesh-' + guid(),
581 | ip: '${keen.ip}',
582 | keen: { addons: [] },
583 | referrer: document.referrer,
584 | app_version: sq.version
585 | };
586 | var push = Array.prototype.push.bind(props.keen.addons);
587 | push(addon('keen:ip_to_geo', { ip: 'ip' }, 'geo'));
588 | push(addon('keen:ua_parser', { ua_string: 'rawUserAgent' }, 'userAgent'));
589 | return props;
590 | });
591 |
592 | return Keen;
593 | })());
594 |
--------------------------------------------------------------------------------
/lib/main.es6:
--------------------------------------------------------------------------------
1 | // Squirt Reader
2 | import {
3 | React,
4 | ReactDOM,
5 | ComponentRegistry,
6 | MessageStore,
7 | PreferencesUIStore,
8 | Message,
9 | Actions,
10 | FocusedContentStore} from 'nylas-exports';
11 | import _ from 'lodash';
12 |
13 | import SquirtParser from './squirt-parser';
14 | import SquirtStore from './squirt.store';
15 | import SquirtNode from './squirt-node.component';
16 | import SquirtControls from './squirt-controls.component';
17 | import SquirtPreferences from './squirt-preferences.component';
18 |
19 | class SquirtReader extends React.Component {
20 | static displayName = 'SquirtReader'
21 |
22 | static propTypes = {
23 | message: React.PropTypes.object.isRequired
24 | }
25 |
26 | constructor(props) {
27 | super(props);
28 | this.parser = new SquirtParser();
29 | this.calculatedOffset = 0;
30 | this.showReader = false;
31 | }
32 |
33 | componentWillMount() {
34 | this.setState({
35 | ready: false,
36 | error: null,
37 | node: null,
38 | showErrors: SquirtStore.getShowErrors(),
39 | });
40 | // Set default wpm on component mount
41 | SquirtStore.setWpm(SquirtStore.getDefaultWpm());
42 | }
43 |
44 | componentDidMount() {
45 | const self = this;
46 | this._storeUnlisten = SquirtStore.listen(this::this._squirtStoreChange);
47 | this.parser
48 | .parse(this.props.message.body)
49 | .then(::this.parser.buildNodes)
50 | .then(::SquirtStore.setNodes)
51 | // .then(::SquirtStore.play)
52 | .catch((error) => {
53 | console.error(error);
54 | self.setState({error: error, node: null, ready: false});
55 | });
56 | }
57 |
58 | componentWillUnmount() {
59 | SquirtStore.clearTimeouts();
60 | SquirtStore.pause();
61 | if (this._storeUnlisten) {
62 | this._storeUnlisten();
63 | }
64 | }
65 |
66 | render() {
67 | // Don't display anything if e-mail is below the threshold
68 | if (this.state.belowThrehold) {
69 | return null;
70 | }
71 | let readerStyles = { visibilitity: 'hidden'};
72 | let widgetStyles = { 'minHeight': '2em'};
73 |
74 | if (this.state.error) {
75 | if (this.state.showErrors){
76 | return
79 | {this.state.error.message}
80 |
81 | }
82 | return null;
83 | }
84 |
85 | if (!this.state.ready) {
86 | return Parsing Text ...
;
89 | }
90 |
91 | let reader = null;
92 |
93 | if (this.state.showReader) {
94 | widgetStyles.height = '8em';
95 | reader = ;
99 | }
100 |
101 | return
102 |
103 | {reader}
104 |
105 | }
106 |
107 | _squirtStoreChange(messageId, state) {
108 | if (messageId === 'squirt.play') {
109 | this.setState({showReader: true});
110 | }
111 | if (messageId === 'squirt.nextWord') {
112 | this.setState({error: null, node: state, ready: true});
113 | }
114 | if (messageId === 'squirt.ready') {
115 | this.setState({
116 | error: null,
117 | node: {},
118 | ready: true ,
119 | belowThrehold: SquirtStore.isBelowThreshold()
120 | });
121 | }
122 | }
123 | }
124 |
125 | // Activate is called when the package is loaded. If your package previously
126 | // saved state using `serialize` it is provided.
127 | //
128 | export function activate(state) {
129 | SquirtStore.init(state);
130 | ComponentRegistry.register(SquirtReader, { role: 'message:BodyHeader' });
131 | this.preferencesTab = new PreferencesUIStore.TabItem({
132 | tabId: 'SquirtPreferences',
133 | displayName: 'Squirt Preferences',
134 | component: SquirtPreferences,
135 | });
136 | PreferencesUIStore.registerPreferencesTab(this.preferencesTab)
137 | }
138 |
139 | // Serialize is called when your package is about to be unmounted.
140 | // You can return a state object that will be passed back to your package
141 | // when it is re-activated.
142 | export function serialize() {
143 | return SquirtStore.serialize();
144 | }
145 |
146 | // This **optional** method is called when the window is shutting down,
147 | // or when your package is being updated or disabled. If your package is
148 | // watching any files, holding external resources, providing commands or
149 | // subscribing to events, release them here.
150 | export function deactivate() {
151 | PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.tabId);
152 | ComponentRegistry.unregister(SquirtReader);
153 | }
154 |
--------------------------------------------------------------------------------
/lib/squirt-controls.component.es6:
--------------------------------------------------------------------------------
1 | import { React } from 'nylas-exports';
2 |
3 | import SquirtStore from './squirt.store';
4 | import SquirtWPMControl from './squirt-wpm-control.component';
5 | import SquirtPlayPauseToggle from './squirt-play-pause-toggle.component';
6 | import SquirtScrubber from './squirt-scrubber.component';
7 | import SquirtRunTime from './squirt-run-time.component';
8 |
9 | export default class SquirtControls extends React.Component {
10 | static displayName = 'SquirtControls'
11 |
12 | constructor(props) {
13 | super(props);
14 | }
15 | render() {
16 | return
17 |
18 |
19 |
20 |
21 |
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/lib/squirt-node.component.es6:
--------------------------------------------------------------------------------
1 | import {
2 | React,
3 | ReactDOM
4 | } from 'nylas-exports';
5 |
6 | import SquirtStore from './squirt.store';
7 |
8 | export default class SquirtNode extends React.Component {
9 | static displayName = 'SquirtNode';
10 | static propTypes = {
11 | node: React.PropTypes.object
12 | }
13 |
14 | constructor(props) {
15 | super(props);
16 | }
17 |
18 | componentDidMount() {
19 | this._storeUnlisten = SquirtStore.listen(this::this._squirtStoreChange);
20 | const node = ReactDOM.findDOMNode(this);
21 | SquirtStore.calcNodeOffsets(node);
22 | }
23 |
24 | componentWillMount() {
25 | this.setState({calculatedOffset: 0})
26 | }
27 |
28 | componentDidUpdate() {
29 | const node = ReactDOM.findDOMNode(this);
30 | SquirtStore.calcNodeOffsets(node);
31 | }
32 |
33 | shouldComponentUpdate(nextProps, nextState) {
34 | const newNode = nextProps.node !== this.props.node
35 | const newOffset = nextState.calculatedOffset !== this.state.calculatedOffset;
36 | return newNode || newOffset;
37 | }
38 |
39 | componentWillUnmount() {
40 | if (this._storeUnlisten) {
41 | this._storeUnlisten();
42 | }
43 | }
44 |
45 | render() {
46 | let styles = {
47 | visibility: 'hidden',
48 | };
49 |
50 | if (this.state.calculatedOffset) {
51 | styles = {
52 | marginLeft: this.state.calculatedOffset,
53 | visibility: 'visible',
54 | }
55 | }
56 | return
57 | {this.props.node.start}
58 | {this.props.node.ORP}
59 | {this.props.node.end}
60 |
61 | }
62 |
63 | _squirtStoreChange(messageId, state) {
64 | if (messageId === 'squirt.offset') {
65 | this.setState({calculatedOffset: state});
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/lib/squirt-parser.es6:
--------------------------------------------------------------------------------
1 | const Readability = require('readabilitySAX').Readability;
2 | const Parser = require('htmlparser2/lib/Parser.js');
3 | const he = require('he');
4 | import _ from 'lodash';
5 |
6 | export default class SquirtParser {
7 | constructor() {
8 | }
9 |
10 | parse(html) {
11 | const readbilityConfig = { type: 'text' };
12 | return new Promise((resolve, reject) => {
13 | if (! _.isString(html)) {
14 | reject(new Error('Empty Email'));
15 | return;
16 | }
17 | const readbaility = new Readability(readbilityConfig);
18 | const parser = new Parser(readbaility, {});
19 | parser.write(html);
20 | const article = readbaility.getArticle();
21 | if (!_.isString(article.text) || article.text.length === 0) {
22 | // Fallback to pure text if no main article found
23 | const text = readbaility.getText();
24 | if (!_.isString(text) || text.length === 0) {
25 | reject(new Error('No Article Text Found'));
26 | return;
27 | }
28 | resolve(text);
29 | }
30 | resolve(article.text);
31 | });
32 | }
33 |
34 | _text2Words(text) {
35 | return text
36 | .replace(/[\,\.\!\:\;](?![\"\'\)\]\}])/g, '$& ')
37 | .split(/[\s]+/g)
38 | .filter((word) => { return word.length; });
39 | }
40 |
41 | _buildNode(nodes, word) {
42 | if (word.length === 1) {
43 | nodes.push({
44 | word,
45 | ORP: word,
46 | start: ' ',
47 | end: ' ',
48 | length: word.length,
49 | });
50 | return nodes;
51 | }
52 | const ORP = this._getORPIndex(word);
53 | nodes.push({
54 | word,
55 | ORP: word[ORP] || ' ',
56 | start: word.slice(0, ORP),
57 | end: word.slice(ORP + 1) || ' ',
58 | length: word.length,
59 | });
60 | return nodes;
61 | }
62 |
63 | // Optimal Regonition Point Calculation
64 | _getORPIndex(word) {
65 | const punctuation = ',.?!:;';
66 | if (!_.isString(word)) {
67 | return 0;
68 | }
69 | // find last meaninful character
70 | let length = word.length;
71 | let lastChar = word[word.length - 1];
72 | if (lastChar === '\n') {
73 | length--;
74 | lastChar = word[word.length - 2];
75 | }
76 | if (_.includes(punctuation, lastChar)) length--;
77 | // Edge case ORP for short words
78 | if (length <= 1) return 0;
79 | if (length === 2 || length === 3) return 1;
80 | // Standard ORP Calclation
81 | // letter just to the left of center
82 | return Math.floor(length / 2 - 1);
83 | }
84 |
85 | buildNodes(text) {
86 | const startSequence = '3\n 2\n 1\n ';
87 | const cleanedText = startSequence + he.decode(text).trim('\n').replace(/\s+\n/g, '\n');
88 | const words = this._text2Words(cleanedText);
89 | return _.reduce(words, this._buildNode.bind(this), []);
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/lib/squirt-play-pause-toggle.component.es6:
--------------------------------------------------------------------------------
1 | import { React } from 'nylas-exports';
2 |
3 | import SquirtStore from './squirt.store';
4 | export default class SquirtPlayPauseToggle extends React.Component {
5 | static displayName = 'SquirtPlayPauseToggle'
6 |
7 | constructor(props) {
8 | super(props);
9 | }
10 |
11 | componentWillMount() {
12 | this.setState({paused: true })
13 | }
14 |
15 | componentDidMount() {
16 | this._storeUnlisten = SquirtStore.listen(this::this._squirtStoreChange);
17 | }
18 |
19 | componentWillUnmount() {
20 | if (this._storeUnlisten) {
21 | this._storeUnlisten();
22 | }
23 | }
24 |
25 | //
26 | render() {
27 | const buttonIcon = this.state.paused ? 'fa fa-play' : 'fa fa-pause';
28 | return