├── .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 =
96 |
97 | 98 |
; 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 28 | render() { 29 | return
30 |
38 | } 39 | 40 | _squirtStoreChange(messageId, state) { 41 | if (messageId === 'squirt.updateWpm') { 42 | this.setState({wpm: state}) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/squirt.store.es6: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | // import Listener, Publisher from 'reflux'; 3 | import NylasStore from 'nylas-store'; 4 | 5 | export default class SquirtStore extends NylasStore { 6 | constructor() { 7 | super(); 8 | 9 | this._state = {}; 10 | 11 | this.maxWpm = 1000; 12 | this.minWpm = 200; 13 | this.wpmStep = 20; 14 | // Minimum length to display reader in # of words 15 | // Default is set to zero, 16 | // User will eventually set through a settings page 17 | this.threshold = 0; 18 | this.defaultWpm = 300; 19 | this.showErrors = true; 20 | // Words per minute 21 | this.wpm = this.defaultWpm; 22 | this.nextNodeTimeoutId = null; 23 | this.paused = true; 24 | this.nodes = []; 25 | this.lastNode = {}; 26 | this.lastNodeIndex = 0; 27 | this.nodeIndex = 0; 28 | this.jumped = false; 29 | 30 | // ///////////////////////////////////////////////////////////////////////// 31 | // Constants for delay calculations 32 | // ///////////////////////////////////////////////////////////////////////// 33 | this.waitAfterShortWord = 1.2; 34 | this.waitAfterComma = 2; 35 | this.waitAfterPeriod = 3; 36 | this.waitAfterParagraph = 3.5; 37 | this.waitAfterLongWord = 1.5; 38 | this.salutations = { 39 | 'Mr.': true, 40 | 'Mrs.': true, 41 | 'Ms.': true, 42 | 'Mx.': true, 43 | }; 44 | this.setWpm(this.wpm); 45 | } 46 | 47 | init(state) { 48 | this.defaultWpm = state.defaultWpm || this.defaultWpm; 49 | this.threshold = state.threshold || this.threshold; 50 | this.showErrors = state.showErrors != undefined ? state.showErrors : this.showErrors; 51 | } 52 | 53 | serialize() { 54 | return { 55 | defaultWpm: this.defaultWpm, 56 | threshold: this.threshold, 57 | showErrors: this.showErrors, 58 | }; 59 | } 60 | 61 | play() { 62 | this.paused = false; 63 | this.trigger('squirt.play'); 64 | this._nextNode(); 65 | } 66 | 67 | pause() { 68 | this.paused = true; 69 | clearTimeout(this.nextNodeTimeoutId); 70 | this.trigger('squirt.pause'); 71 | } 72 | 73 | jump(index) { 74 | this.jumped = true; 75 | this.nodeIndex = index; 76 | this.trigger('squirt.jumped'); 77 | } 78 | 79 | // 80 | // restart() { 81 | // 82 | // } 83 | // 84 | // rewind() { 85 | // 86 | // } 87 | 88 | getWpm() { 89 | return _.clone(this.wpm); 90 | } 91 | 92 | getMaxWpm() { 93 | return _.clone(this.maxWpm); 94 | } 95 | 96 | getMinWpm() { 97 | return _.clone(this.minWpm); 98 | } 99 | 100 | getDefaultWpm() { 101 | return _.clone(this.defaultWpm); 102 | } 103 | 104 | setDefaultWpm(newDefaultWpm) { 105 | this.defaultWpm = newDefaultWpm; 106 | } 107 | 108 | getThreshold() { 109 | return _.clone(this.threshold); 110 | } 111 | 112 | setThrehold(newThreshold) { 113 | this.threshold = newThreshold; 114 | } 115 | 116 | setShowErrors(newShowErrors) { 117 | this.showErrors = newShowErrors; 118 | } 119 | 120 | getShowErrors() { 121 | return _.clone(this.showErrors); 122 | } 123 | 124 | isBelowThreshold() { 125 | return this.getNumberOfNodes() < this.threshold; 126 | } 127 | 128 | getNumberOfNodes() { 129 | return _.get(this, 'nodes.length', 0); 130 | } 131 | 132 | getCurrentIndex() { 133 | return _.get(this, 'nodeIndex'); 134 | } 135 | 136 | setWpm(wpm) { 137 | this.wpm = wpm; 138 | // 60 seconds * 1000 milliseconds / words per minute 139 | this.intervalMilliseconds = 60 * 1000 / wpm; 140 | this.trigger('squirt.updateWpm', this.getWpm()); 141 | } 142 | 143 | incrementWpm() { 144 | if (this.wpm >= this.maxWpm) { 145 | return; 146 | } 147 | this.setWpm(this.wpm + this.wpmStep); 148 | } 149 | 150 | decrementWpm() { 151 | if (this.wpm <= this.minWpm) { 152 | return; 153 | } 154 | this.setWpm(this.wpm - this.wpmStep); 155 | } 156 | 157 | setNodes(nodes) { 158 | if (!nodes.length) { 159 | throw new Error('No text nodes created'); 160 | } 161 | this.nodes = nodes; 162 | this.lastNode = {}; 163 | this.lastNodeIndex = 0; 164 | this.nodeIndex = 0; 165 | this.trigger('squirt.ready'); 166 | } 167 | 168 | getRunTime() { 169 | return _.reduce(this.nodes, (time, node) => { 170 | return time + (this.intervalMilliseconds * this._getDelay(node)); 171 | }, 0); 172 | } 173 | 174 | getRunTimeString() { 175 | const runTime = this.getRunTime(); 176 | const second = 1000; 177 | const minute = second * 60; 178 | // minutes 179 | if (runTime >= minute) { 180 | const minutes = runTime / minute; 181 | return _.round(minutes, 2) + ' mins'; 182 | } 183 | // seconds 184 | const seconds = runTime / second; 185 | return _.round(seconds) + ' secs'; 186 | } 187 | clearTimeouts() { 188 | clearTimeout(this.nextNodeTimeoutId); 189 | } 190 | 191 | _getDelay(node, jumped) { 192 | const word = node.word; 193 | // If jumped to position, give longest delay to allow for readjustment 194 | if (jumped) return this.waitAfterPeriod; 195 | if (_.get(this.salutations[word])) return 1; 196 | let lastChar = word[word.length - 1]; 197 | // Ignore 198 | if (lastChar.match('”|"')) lastChar = word[word.length]; 199 | // Paragraph 200 | if (lastChar === '\n') return this.waitAfterParagraph; 201 | // Peroid length pause 202 | if ('.!?'.indexOf(lastChar) !== -1) return this.waitAfterPeriod; 203 | // Comma length pause 204 | if (',;:–'.indexOf(lastChar) !== -1) return this.waitAfterComma; 205 | // Short Word 206 | if (word.length < 4) return this.waitAfterShortWord; 207 | // Long Word 208 | if (word. length > 11) return this.waitAfterLongWord; 209 | // Default to 1 210 | return 1; 211 | } 212 | 213 | _incrementNodeIndex(increment) { 214 | const returnValue = this.nodeIndex; 215 | this.nodeIndex += increment || 1; 216 | this.nodeIndex = Math.max(0, this.nodeIndex); 217 | return returnValue; 218 | } 219 | 220 | _nextNode() { 221 | const nextIndex = this._incrementNodeIndex(); 222 | if (nextIndex > this.nodes.length) { 223 | this.trigger('squirt.finalWord'); 224 | return; 225 | } 226 | this.trigger('squirt.nextWord', this.lastNode); 227 | this.lastNode = this.nodes[nextIndex]; 228 | 229 | if (this.paused || !this.lastNode) return; 230 | 231 | const delay = this.intervalMilliseconds * this._getDelay(this.lastNode, this.jumped); 232 | this.jumped = false; 233 | this.nextNodetimeoutId = setTimeout(this._nextNode.bind(this), delay); 234 | return; 235 | } 236 | 237 | calcNodeOffsets(node) { 238 | if (node.children.length !== 3) { 239 | console.log('Unexpected number of node children:', node); 240 | } 241 | // Get width of start ORP 242 | const startWidth = node.children[0].getBoundingClientRect().width; 243 | const ORPWidth = node.children[1].getBoundingClientRect().width; 244 | // start width + ORP/2 = offset 245 | // plus a fiddle-factor (ff) 246 | const ff = 1; 247 | const offset = startWidth + Math.floor(ORPWidth / 2) + ff; 248 | this.trigger('squirt.offset', -offset); 249 | return; 250 | } 251 | 252 | } 253 | 254 | export default new SquirtStore(); 255 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Squirt", 3 | "icon": "squirt-icon.png", 4 | "main": "./lib/main", 5 | "version": "0.1.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/HarleyKwyn/squirt-reader-N1-plugin.git" 9 | }, 10 | "scripts": { 11 | "test": "./node_modules/.bin/mocha --compilers es6:babel-core/register spec/*.es6" 12 | }, 13 | "engines": { 14 | "nylas": ">=0.4.40" 15 | }, 16 | "description": "Speed reader plugin for the Nylas N1 Client", 17 | "dependencies": { 18 | "babel-plugin-transform-class-properties": "6.3.13", 19 | "babel-plugin-transform-es2015-modules-commonjs": "6.3.16", 20 | "babel-preset-es2015": "6.3.13", 21 | "babel-preset-react": "6.3.13", 22 | "babel-register": "6.3.13", 23 | "he": "1.1.0", 24 | "htmlparser2": "3.9.0", 25 | "lodash": "4.12.0", 26 | "react": "0.14.3", 27 | "react-rangeslider": "1.0.0", 28 | "readabilitySAX": "1.6.1", 29 | "reflux": "0.3.0", 30 | "babel-register": "6.3.13" 31 | }, 32 | "license": "MIT", 33 | "devDependencies": { 34 | "chai": "3.4.1", 35 | "eslint": "1.10.3", 36 | "eslint-config-airbnb": "2.1.1", 37 | "eslint-plugin-babel": "3.0.0", 38 | "eslint-plugin-react": "3.13.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /resources/loremipsum.html: -------------------------------------------------------------------------------- 1 |

HTML Ipsum Presents

2 | 3 |

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

4 | 5 |

Header Level 2

6 | 7 |
    8 |
  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. 9 |
  3. Aliquam tincidunt mauris eu risus.
  4. 10 |
11 | 12 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

13 | 14 |

Header Level 3

15 | 16 | 20 | 21 |

22 | #header h1 a {
23 | 	display: block;
24 | 	width: 300px;
25 | 	height: 80px;
26 | }
27 | 
28 | -------------------------------------------------------------------------------- /spec/squirt-parser.spec.es6: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import _ from 'lodash'; 3 | import SquirtParser from './../lib/squirt-parser'; 4 | import fs from 'fs'; 5 | const ipsumhtml = fs.readFileSync('resources/loremipsum.html', 'UTF-8'); 6 | let parser = {}; 7 | 8 | describe('SquirtParser', () => { 9 | beforeEach(() => { 10 | parser = new SquirtParser(); 11 | }); 12 | it('should return a promise', (done) => { 13 | const promise = parser.parse(ipsumhtml); 14 | expect(promise).to.exist; 15 | promise.then((text) => { 16 | console.log(text); 17 | done(); 18 | }).catch((error) => { 19 | console.error(error); 20 | done(); 21 | }); 22 | }); 23 | describe('._getORPIndex', () => { 24 | it('should return appropriate index for given words', () => { 25 | expect(parser._getORPIndex('foo')).to.equal(1); 26 | expect(parser._getORPIndex('')).to.equal(0); 27 | expect(parser._getORPIndex('bar;')).to.equal(1); 28 | expect(parser._getORPIndex('bar;\n')).to.equal(1); 29 | expect(parser._getORPIndex('testing')).to.equal(2); 30 | }); 31 | }); 32 | describe('._buildNode', () => { 33 | it('Should buidl appropriate node objects for a given long word', () => { 34 | const expected = [{ start: 'te', ORP: 's', end: 'ting', length: 'testing'.length, word: 'testing' }]; 35 | expect(parser._buildNode([], 'testing')).to.deep.equal(expected); 36 | }); 37 | it('should handle single letters', () => { 38 | const expected = [{ start: '', ORP: 'I', end: '', length: 1, word: 'I' }]; 39 | expect(parser._buildNode([], 'I')).to.deep.equal(expected); 40 | }); 41 | }); 42 | 43 | describe('.buildNodes', () => { 44 | it('sould build appropriate nodes for given string', () => { 45 | const testText = 'I love testing'; 46 | const expectedNodes = [ 47 | { 48 | word: '3', 49 | start: '', 50 | ORP: '3', 51 | end: '', 52 | length: 1, 53 | }, 54 | { 55 | word: '2', 56 | start: '', 57 | ORP: '2', 58 | end: '', 59 | length: 1, 60 | }, 61 | { 62 | word: '1', 63 | start: '', 64 | ORP: '1', 65 | end: '', 66 | length: 1, 67 | }, 68 | { 69 | word: 'I', 70 | start: '', 71 | ORP: 'I', 72 | end: '', 73 | length: 1, 74 | }, 75 | { 76 | word: 'love', 77 | start: 'l', 78 | ORP: 'o', 79 | end: 've', 80 | length: 4, 81 | }, 82 | { 83 | word: 'testing', 84 | start: 'te', 85 | ORP: 's', 86 | end: 'ting', 87 | length: 7, 88 | }, 89 | ]; 90 | const nodes = parser.buildNodes(testText); 91 | expect(nodes).to.deep.equal(expectedNodes); 92 | }); 93 | }); 94 | 95 | describe('._text2Words', () => { 96 | it('should create an array of strings', (done) => { 97 | const promise = parser.parse(ipsumhtml); 98 | promise.then((text) => { 99 | const words = parser._text2Words(text); 100 | expect(words).to.be.an.instanceof(Array); 101 | expect(_.sample(words)).to.be.a('string'); 102 | done(); 103 | }) 104 | .catch((error) => { 105 | console.error(error); 106 | done(); 107 | }); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /spec/squirt.strore.spec.es6: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import _ from 'lodash'; 3 | import SquirtParser from './../lib/squirt-parser'; 4 | import SquirtStore from './../lib/squirt.store'; 5 | import { NylasStore } from 'nylas-exports'; 6 | 7 | import fs from 'fs'; 8 | const ipsumhtml = fs.readFileSync('resources/loremipsum.html', 'UTF-8'); 9 | let parser = {}; 10 | 11 | describe('SquirtStore', () => { 12 | // beforeEach(() => { 13 | // parser = new SquirtParser(); 14 | // }); 15 | it('should be an instance of NylasStore', () => { 16 | let store = new SquirtStore(); 17 | expect(store).to.be.an.instanceof(NylasStore); 18 | }); 19 | describe('._getORPIndex', () => { 20 | it('should return appropriate index for given words', () => { 21 | expect(parser._getORPIndex('foo')).to.equal(1); 22 | expect(parser._getORPIndex('')).to.equal(0); 23 | expect(parser._getORPIndex('bar;')).to.equal(1); 24 | expect(parser._getORPIndex('bar;\n')).to.equal(1); 25 | expect(parser._getORPIndex('testing')).to.equal(2); 26 | }); 27 | }); 28 | describe('._buildNode', () => { 29 | it('Should buidl appropriate node objects for a given long word', () => { 30 | const expected = [{ start: 'te', ORP: 's', end: 'ting', length: 'testing'.length, word: 'testing' }]; 31 | expect(parser._buildNode([], 'testing')).to.deep.equal(expected); 32 | }); 33 | it('should handle single letters', () => { 34 | const expected = [{ start: '', ORP: 'I', end: '', length: 1, word: 'I' }]; 35 | expect(parser._buildNode([], 'I')).to.deep.equal(expected); 36 | }); 37 | }); 38 | 39 | describe('.buildNodes', () => { 40 | it('sould build appropriate nodes for given string', () => { 41 | const testText = 'I love testing'; 42 | const expectedNodes = [ 43 | { 44 | word: '3', 45 | start: '', 46 | ORP: '3', 47 | end: '', 48 | length: 1, 49 | }, 50 | { 51 | word: '2', 52 | start: '', 53 | ORP: '2', 54 | end: '', 55 | length: 1, 56 | }, 57 | { 58 | word: '1', 59 | start: '', 60 | ORP: '1', 61 | end: '', 62 | length: 1, 63 | }, 64 | { 65 | word: 'I', 66 | start: '', 67 | ORP: 'I', 68 | end: '', 69 | length: 1, 70 | }, 71 | { 72 | word: 'love', 73 | start: 'l', 74 | ORP: 'o', 75 | end: 've', 76 | length: 4, 77 | }, 78 | { 79 | word: 'testing', 80 | start: 'te', 81 | ORP: 's', 82 | end: 'ting', 83 | length: 7, 84 | }, 85 | ]; 86 | const nodes = parser.buildNodes(testText); 87 | expect(nodes).to.deep.equal(expectedNodes); 88 | }); 89 | }); 90 | 91 | describe('._text2Words', () => { 92 | it('should create an array of strings', (done) => { 93 | const promise = parser.parse(ipsumhtml); 94 | promise.then((text) => { 95 | const words = parser._text2Words(text); 96 | expect(words).to.be.an.instanceof(Array); 97 | expect(_.sample(words)).to.be.a('string'); 98 | done(); 99 | }) 100 | .catch((error) => { 101 | console.error(error); 102 | done(); 103 | }); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /squirt-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kwyn/squirt-reader-N1-plugin/52e02af7c3824fd99959ec327f55eaeb452f269a/squirt-icon.png -------------------------------------------------------------------------------- /stylesheets/main.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | @import "ui-mixins"; 3 | 4 | @widget-height: 8em; 5 | @control-height: 2em; 6 | @reader-height: @widget-height - @control-height; 7 | @reader-font-size: 1.5em; 8 | @widget-background-color: transparent; 9 | 10 | .squirt__container { 11 | min-height: @widget-height; 12 | display: block; 13 | box-sizing: border-box; 14 | -webkit-print-color-adjust: exact; 15 | margin-bottom: 1em; 16 | margin-top: 1em; 17 | border: .1em solid @border-color-primary; 18 | border-radius: .25em; 19 | color: @text-color; 20 | background-color: @widget-background-color; 21 | &.error { 22 | padding-top: 0.2em; 23 | text-align: center; 24 | background-color: @background-color-error; 25 | border: none; 26 | } 27 | &.info { 28 | padding-top: 0.2em; 29 | text-align: center; 30 | background-color: @background-color-info; 31 | border:none; 32 | } 33 | } 34 | 35 | .squirt__reader { 36 | display: block; 37 | position: absolute; 38 | width: 50%; 39 | left: 50%; 40 | height: @reader-height; 41 | border-left: 1px solid @border-color-primary; 42 | } 43 | 44 | @reader-top-offset: ( @reader-height / 2.1) - @reader-font-size; 45 | .squirt__node { 46 | position:relative; 47 | font-size: @reader-font-size; 48 | margin-top: @reader-top-offset; 49 | } 50 | 51 | @spacing: .5em; 52 | .squirt__reader-space { 53 | position: absolute; 54 | width: 2px; 55 | left: -1px; 56 | top: @reader-top-offset + @spacing; 57 | height: @reader-font-size + (2 * @spacing); 58 | background-color: @background-primary; 59 | } 60 | 61 | .ORP { 62 | color: @text-color-link; 63 | } 64 | 65 | .squirt__controls { 66 | display: flex; 67 | flex-direction: row; 68 | align-items: center; 69 | height: @control-height; 70 | border-bottom: 1px solid @border-color-primary; 71 | } 72 | 73 | .wpm__container { 74 | flex-grow: 1; 75 | display: flex; 76 | max-height: 100%; 77 | } 78 | 79 | .wpm__value { 80 | max-height:100%; 81 | } 82 | 83 | @range-track-color: @text-color-subtle; 84 | @slider-thumb-radius: 1em; 85 | @slider-track-height: 0.2em; 86 | @slider-thumb-color: @text-color-subtle; 87 | 88 | .squirt__scrubber { 89 | flex-grow: 3; 90 | align-items: flex-end; 91 | > input { 92 | margin-top: .25em; 93 | } 94 | } 95 | 96 | .squirt__run-time { 97 | flex-grow: 2; 98 | justify-content: center; 99 | text-align: center; 100 | } 101 | 102 | button{ 103 | max-height: 100%; 104 | background-color: transparent; 105 | border: none; 106 | &:hover { 107 | cursor: pointer; 108 | } 109 | &.wpm__decrement, &.wpm__increment { 110 | flex-grow: 1; 111 | } 112 | &.pause-play__toggle { 113 | flex-grow: 1 114 | } 115 | } 116 | --------------------------------------------------------------------------------