1/* Speakable Text-To-Speech player 0.2.0 | https://github.com/tollwerk/speakable */ 2/* eslint no-param-reassign: ["error", { "props": false }] */ 3(function iffe(w, d) { 4 /** 5 * Speech Synthesis Voices 6 * 7 * @type {SpeechSynthesisVoice[]} 8 */ 9 var voices = []; 10 /** 11 * Global Speech Utterance 12 * 13 * @type {SpeechSynthesisUtterance} 14 */ 15 var speechUtterance = null; 16 /** 17 * Regular expression to match punctuation 18 * 19 * @type {RegExp} 20 */ 21 var punctuation = /[â'â`ââ"[\](){}â¦,.!;?\-:\u0964\u0965]\s*$/; 22 /** 23 * Characters that should be stripped from output 24 * 25 * @type {RegExp} 26 */ 27 var dontspeak = /(\w)[·â§*:](\w)/gi; 28 /** 29 * Default options 30 * 31 * @type {{multivoice: boolean, selector: string, 32 * l18n: {play: string, stop: string, progress: string, pause: string}}} 33 */ 34 var defaultOptions = { 35 selector: '.spkbl', 36 local: true, 37 multivoice: true, 38 hidden: false, 39 player: null, 40 l18n: { 41 play: 'Read text', 42 pause: 'Pause', 43 progress: 'Progress', 44 stop: 'Resume' 45 } 46 }; 47 /** 48 * Block level elements 49 * 50 * @type {string[]} 51 */ 52 var blockLevelElements = ['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd', 'div', 'dl', 53 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 54 'hgroup', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul']; 55 /** 56 * Test whether an element is a block level element 57 * 58 * @param {Element} element element 59 * 60 * @returns {boolean} Is block level element 61 */ 62 function isBlockLevelElement(element) { 63 return blockLevelElements.indexOf(element.tagName.toLowerCase()) !== -1; 64 } 65 /** 66 * Simple object check 67 * 68 * @param item Item 69 * 70 * @returns {boolean} Is object 71 */ 72 function isObject(item) { 73 return (item && typeof item === 'object' && !Array.isArray(item)); 74 } 75 /** 76 * Deep merge multiple objects 77 * 78 * @param target Target object 79 * @param sources Source object(s) 80 */ 81 function mergeDeep(target) { 82 var sources = []; 83 for (var _i = 1; _i < arguments.length; _i++) { 84 sources[_i - 1] = arguments[_i]; 85 } 86 if (!sources.length) { 87 return target; 88 } 89 var source = sources.shift(); 90 if (isObject(target) && isObject(source)) { 91 for (var key in source) { 92 if (Object.prototype.hasOwnProperty.call(source, key) && isObject(source[key])) { 93 if (!target[key]) 94 Object.assign(target, (_a = {}, _a[key] = {}, _a)); 95 mergeDeep(target[key], source[key]); 96 } 97 else { 98 Object.assign(target, (_b = {}, _b[key] = source[key], _b)); 99 } 100 } 101 } 102 return mergeDeep.apply(void 0, [target].concat(sources)); 103 var _a, _b; 104 } 105 /** 106 * Cast a value to a Boolean if possible 107 * 108 * @param {String} val Value 109 * @return {boolean|String} Converted value 110 */ 111 function castToBool(val) { 112 if ((val === '1') || (val.toLowerCase() === 'true')) { 113 return true; 114 } 115 if ((val === '0') || (val.toLowerCase() === 'false')) { 116 return false; 117 } 118 return val; 119 } 120 /** 121 * Abstract syntax tree parser 122 * 123 * @param {String} language Language 124 * @param {Boolean} multivoice Multiple voices 125 * 126 * @constructor 127 */ 128 function AstParser(language, multivoice) { 129 this.lang = language; 130 this.multivoice = multivoice; 131 this.items = []; 132 } 133 /** 134 * Recursively parse an element 135 * 136 * The method recursively traverses the DOM, collects readable elements, determines their language, filters out 137 * elements that should be skipped and extracts the readable text. The result is a hierarchichal structure of 138 * readable elements, stored in the parser's .items property, somewhat looking like this: 139 * 140 * items = [ 141 * { 142 * lang: "en", 143 * node: text, // DOM node reference 144 * type: 0, 145 * text: "Readable text" 146 * }, 147 * { 148 * lang: "en", 149 * node: p, // DOM node reference 150 * type: 2, 151 * items: [...] 152 * }, 153 * ... 154 * ] 155 * 156 * The "type" value specifies the item type: 157 * 158 * 0: Text node 159 * 1: Inline element 160 * 2: Block element 161 * 162 * @param {Element} element Element 163 * 164 * @returns {Object[]} Items 165 */ 166 AstParser.prototype.parse = function parse(element) { 167 var _this = this;
168 element.childNodes.forEach(function (c) { 169 if (c.nodeType === Element.ELEMENT_NODE) { 170 if (!c.hasAttribute('data-spkbl-skip')) { 171 var lang = _this.multivoice ? (c.lang || _this.lang) : _this.lang; 172 _this.items.push({ 173 type: 1 + isBlockLevelElement(c), 174 lang: lang, 175 node: c, 176 items: (new AstParser(lang, _this.multivoice)).parse(c) 177 }); 178 } 179 } 180 else if (c.nodeType === Element.TEXT_NODE) { 181 var text = c.nodeValue; 182 if (text.trim().length) { 183 text = text.replace(/[\s\r\n]+/g, ' '); 184 text = text.replace(dontspeak, '$1$2'); 185 _this.items.push({ 186 type: 0, 187 lang: _this.lang, 188 node: c, 189 text: text 190 }); 191 } 192 } 193 }); 194 return this.items; 195 }; 196 /** 197 * Create a new sentence 198 * 199 * @param {String} lang Language 200 * 201 * @return {{chunks: [], lang: *}} 202 */ 203 AstParser.prototype.createSentence = function createSentence(lang) { 204 return { 205 lang: lang, 206 chunks: [] 207 }; 208 }; 209 /** 210 * Parse an element into readable chunks 211 * 212 * @param {Element} element Element 213 * 214 * @returns {Array} Readable chunks 215 * @private 216 */ 217 AstParser.prototype.createChunks = function createChunks(element) { 218 var _this = this; 219 var chunks = []; 220 var sentence = null; 221 var chunksRecursive = function (c) { 222 if (sentence === null) { 223 sentence = _this.createSentence(c.lang); 224 if (c.type) { 225 c.items.forEach(chunksRecursive); 226 if (sentence && sentence.chunks.length) { 227 chunks.push(sentence); 228 } 229 sentence = null; 230 } 231 else { 232 sentence.chunks.push({ 233 node: c.node, 234 text: c.text 235 }); 236 } 237 } 238 else { 239 switch (c.type) { 240 case 2: 241 if (sentence.chunks.length) { 242 chunks.push(sentence); 243 sentence = _this.createSentence(c.lang); 244 } 245 else { 246 sentence.lang = c.lang; 247 } 248 c.items.forEach(chunksRecursive); 249 if (sentence && sentence.chunks.length) { 250 chunks.push(sentence); 251 } 252 sentence = null; 253 break; 254 case 1: 255 if (c.node.tagName.toUpperCase() === 'BR') { 256 var clen = sentence.chunks.length; 257 if (clen) { 258 var lastText = sentence.chunks[clen - 1].text; 259 if (!punctuation.test(lastText)) { 260 sentence.chunks.push({ 261 node: c.node, 262 text: ' . ' 263 }); 264 } 265 else if (!/\s$/.test(lastText)) { 266 sentence.chunks.push({ 267 node: c.node, 268 text: ' ' 269 }); 270 } 271 } 272 } 273 else if (c.lang === sentence.lang) { 274 c.items.forEach(chunksRecursive); 275 } 276 else { 277 var lang = sentence.lang; 278 if (sentence.chunks.length) { 279 chunks.push(sentence); 280 } 281 sentence = _this.createSentence(c.lang);
282 c.items.forEach(chunksRecursive); 283 if (sentence.chunks.length) { 284 chunks.push(sentence); 285 } 286 sentence = _this.createSentence(lang); 287 } 288 break; 289 default: 290 sentence.chunks.push({ 291 node: c.node, 292 text: c.text 293 }); 294 } 295 } 296 }; 297 this.parse(element) 298 .forEach(chunksRecursive); 299 if (sentence && sentence.chunks.length) { 300 chunks[chunks.length] = sentence; 301 } 302 return chunks; 303 }; 304 /** 305 * Collapse a chunk's text nodes and build a node source map 306 * 307 * @param {Object} chunk Chunk 308 * 309 * @return {{sourcemap: {}, text: string, lang: string}} 310 */ 311 AstParser.prototype.map = function map(chunk) { 312 var mappedChunk = { 313 lang: chunk.lang, 314 text: '', 315 map: new Map() 316 }; 317 chunk.chunks.forEach(function (c) { 318 // const wrap = d.createElementNS('https://tollwerk.de/speakable/1.0', 's:s'); 319 // c.node.after(wrap); 320 // wrap.appendChild(c.node); 321 // mappedChunk.map.set(mappedChunk.text.length, wrap); 322 mappedChunk.map.set(mappedChunk.text.length, c.node); 323 mappedChunk.text += c.text; 324 }); 325 return mappedChunk; 326 }; 327 /** 328 * Parse an element and return consolidated readable chunks 329 * 330 * @param {Element} element Element 331 * 332 * @returns {Array} Readable chunks 333 */ 334 AstParser.prototype.chunked = function chunked(element) { 335 var chunks = this.createChunks(element); 336 if (!chunks.length) { 337 return []; 338 } 339 // Run through all chunks, collapse the text nodes and build corresponding sourcemaps 340 var chunkMaps = chunks.map(this.map); 341 var consolidated = [chunkMaps.shift()]; 342 var _loop_1 = function() { 343 var chunk = chunkMaps.shift(); 344 var last = consolidated.length - 1; 345 if (chunk.lang === consolidated[last].lang) { 346 if (!punctuation.test(consolidated[last].text)) { 347 consolidated[last].text += '. '; 348 } 349 consolidated[last].text = consolidated[last].text.trim() + " "; 350 var offset_1 = consolidated[last].text.length; 351 consolidated[last].text += chunk.text; 352 chunk.map.forEach(function (value, key) { 353 consolidated[last].map.set(offset_1 + key, value); 354 }); 355 } 356 else { 357 consolidated.push(chunk); 358 } 359 }; 360 while (chunkMaps.length) { 361 _loop_1(); 362 } 363 return consolidated; 364 }; 365 /** 366 * Speakable 367 * 368 * @param {Element} element Speakable 369 * @param {Object} options Options 370 * 371 * @constructor 372 */ 373 function Speakable(element, options) { 374 this.element = element; 375 this.options = this.configure(options, 'data-spkbl'); 376 this.utterances = []; 377 this.currentUtterance = 0; 378 this.length = 0; 379 this.offset = 0; 380 this.progress = 0; 381 this.paused = false; 382 this.nextOnResume = false; 383 this.player = null; 384 this.controls = {}; 385 var factory = this.defaultPlayer; 386 if (this.options.player) { 387 if (typeof this.options.player === 'function') { 388 factory = this.options.player; 389 } 390 else if (typeof w[this.options.player] === 'function') { 391 factory = w[this.options.player]; 392 } 393 } 394 this.buildPlayer(factory); 395 // Parse the element contents 396 var astParser = new AstParser(this.determineLanguage(this.element) || 'en', this.options.multivoice); 397 this.setUtterances(astParser.chunked(this.element)); 398 // Inject the player 399 this.injectPlayer(); 400 } 401 /** 402 * Configure this instance by data attributes 403 * 404 * @param {Object} options Options 405 * @param {String} prefix Attribute prefix 406 * 407 * @private 408 */ 409 Speakable.prototype.configure = function configure(options, prefix) { 410 var configured = {}; 411 for (var o in options) { 412 if (Object.prototype.hasOwnProperty.call(options, o)) { 413 var attr = prefix + "-" + o; 414 if (isObject(options[o])) { 415 configured[o] = this.configure(options[o], attr); 416 } 417 else if (this.element.hasAttribute(attr)) { 418 configured[o] = castToBool(this.element.getAttribute(attr)); 419 } 420 else { 421 configured[o] = options[o]; 422 } 423 } 424 } 425 return configured; 426 }; 427 /** 428 * Determine element language 429 * 430 * @param {Element} element Element 431 * 432 * @private 433 */ 434 Speakable.prototype.determineLanguage = function determineLanguage(element) { 435 var lang = element.lang; 436 return lang || (element.parentNode ? this.determineLanguage(element.parentNode) : null); 437 }; 438 /** 439 * Create the default player 440 * 441 * @param {Speakable} spkbl Speakable reference 442 * @return {Object} Player elements 443 * @private 444 */ 445 Speakable.prototype.defaultPlayer = function defaultPlayer(spkbl) { 446 var player = { 447 player: d.createElement('div'), 448 controls: {} 449 }; 450 player.controls.play = player.player.appendChild(d.createElement('button')); 451 player.controls.play.innerHTML = spkbl.options.l18n.play; 452 player.controls.pause = player.player.appendChild(d.createElement('button')); 453 player.controls.pause.innerHTML = spkbl.options.l18n.pause; 454 player.controls.progress = player.player.appendChild(d.createElement('progress')); 455 player.controls.progress.innerHTML = '0%'; 456 player.controls.stop = player.player.appendChild(d.createElement('button')); 457 player.controls.stop.innerHTML = spkbl.options.l18n.stop; 458 return player; 459 }; 460 /** 461 * Build the player 462 * 463 * @param {Function} factory Player factory 464 * @private 465 */ 466 Speakable.prototype.buildPlayer = function buildPlayer(factory) { 467 var instance = factory(this); 468 this.player = instance.player; 469 this.controls = instance.controls;
470 this.player.classList.add('spkbl-player', 'spkbl-player--inactive'); 471 this.player.role = 'group'; 472 if (this.options.hidden) { 473 this.player.setAttribute('aria-hidden', 'true'); 474 } 475 // Play button 476 this.controls.play.type = 'button'; 477 this.controls.play.classList.add('spkbl-ctrl', 'spkbl-ctrl--play'); 478 this.controls.play.addEventListener('click', this.play.bind(this)); 479 // Pause button 480 this.controls.pause.type = 'button'; 481 this.controls.pause.classList.add('spkbl-ctrl', 'spkbl-ctrl--pause'); 482 this.controls.pause.addEventListener('click', this.pause.bind(this)); 483 this.controls.pause.setAttribute('aria-pressed', 'false'); 484 // Progress bar 485 this.controls.progress.classList.add('spkbl-ctrl', 'spkbl-ctrl--progress'); 486 this.controls.progress.max = '100'; 487 this.controls.progress.value = '0'; 488 this.controls.progress.setAttribute('aria-label', this.options.l18n.progress); 489 this.controls.progress.setAttribute('aria-valuenow', '0'); 490 this.controls.progress.setAttribute('aria-valuemin', '0'); 491 this.controls.progress.setAttribute('aria-valuemax', '100'); 492 this.controls.progress.setAttribute('role', 'progressbar'); 493 // Stop button 494 this.controls.stop.type = 'button'; 495 this.controls.stop.classList.add('spkbl-ctrl', 'spkbl-ctrl--stop'); 496 this.controls.stop.addEventListener('click', this.stop.bind(this)); 497 }; 498 /** 499 * Start playing 500 * 501 * @param {Array} utterances Utterances 502 * 503 * @private 504 */ 505 Speakable.prototype.setUtterances = function setUtterances(utterances) { 506 var _this = this; 507 this.length = 0; 508 this.utterances = utterances.map(function (u) { 509 u.length = u.text.length; 510 _this.length += u.length + 1; 511 return u; 512 }); 513 this.length += 1; 514 }; 515 /** 516 * Start playing 517 * 518 * @param {SpeechSynthesisEvent} e Event 519 */ 520 Speakable.prototype.play = function play(e) { 521 if (Speakable.current) { 522 Speakable.current.halt(); 523 } 524 Speakable.current = this; 525 this.player.classList.add('spkbl-player--active'); 526 this.player.classList.remove('spkbl-player--inactive'); 527 this.controls.pause.focus(); 528 d.addEventListener('keyup', this.escape.bind(this)); 529 this.currentUtterance = -1; 530 this.offset = 0; 531 this.progress = 0; 532 speechSynthesis.cancel(); 533 // console.log(speechUtterance); 534 speechUtterance.onboundary = this.boundary.bind(this); 535 speechUtterance.onend = this.next.bind(this); 536 this.next(e); 537 }; 538 /** 539 * Escape the player 540 * 541 * @param {KeyboardEvent} e Event 542 */ 543 Speakable.prototype.escape = function escape(e) { 544 var evt = e || window.event; 545 if (('key' in evt) ? (evt.key === 'Escape' || evt.key === 'Esc') : (evt.keyCode === 27)) { 546 this.stop(); 547 } 548 }; 549 /** 550 * Play the next utterance 551 * 552 * @param {SpeechSynthesisEvent} e Event 553 */ 554 Speakable.prototype.next = function next(e) { 555 if (this.paused) { 556 this.nextOnResume = true; 557 speechSynthesis.cancel(); 558 } 559 else if (this.utterances.length > (this.currentUtterance + 1)) { 560 if (this.currentUtterance >= 0) { 561 this.offset += this.utterances[this.currentUtterance].length + 1; 562 } 563 this.currentUtterance += 1; 564 var utterance = this.utterances[this.currentUtterance]; 565 speechUtterance.text = utterance.text; 566 speechUtterance.voice = this.getUtteranceVoice(utterance); 567 speechSynthesis.speak(speechUtterance); 568 } 569 else { 570 this.stop(e); 571 } 572 }; 573 /** 574 * Find the voice for an utterance 575 * 576 * @param {Object} utterance Utterance 577 * 578 * @returns {SpeechSynthesisVoice} Voice 579 * 580 * @private 581 */ 582 Speakable.prototype.getUtteranceVoice = function getUtteranceVoice(utterance) { 583 if (!utterance.voice) { 584 var locale_1 = utterance.lang; 585 var lang_1 = locale_1.split('-') 586 .shift(); 587 utterance.voice = voices.find(function (v) { return (v.lang === locale_1) || (v.lang === lang_1) 588 || v.lang.startsWith(locale_1 + "-") || v.lang.startsWith(lang_1 + "-"); }) 589 || voices.find(function (v) { return v.default; }) || voices[0]; 590 } 591 return utterance.voice; 592 }; 593 /** 594 * Boundary handler 595 * 596 * @param {SpeechSynthesisEvent} e Event 597 */ 598 Speakable.prototype.boundary = function boundary(e) { 599 this.progress = Math.round((100 * (this.offset + e.charIndex)) / this.length); 600 this.controls.progress.value = this.progress; 601 this.controls.progress.setAttribute('aria-valuenow', this.progress); 602 this.controls.progress.textContent = this.progress + " % "; 603 // console.debug(this.progress, e.name, speechUtterance.text.substr(e.charIndex, e.charLength)); 604 }; 605 /** 606 * Pause / Resume playing 607 */ 608 Speakable.prototype.pause = function pause() { 609 speechSynthesis[this.togglePause(this.paused) ? 'pause' : 'resume'](); 610 if (this.nextOnResume) { 611 this.nextOnResume = false;
612 this.next(); 613 } 614 }; 615 /** 616 * Toggle pause button 617 * 618 * @var {Boolean} paused Is paused 619 * 620 * @return {Boolean} Is paused 621 */ 622 Speakable.prototype.togglePause = function togglePause(paused) { 623 this.paused = !paused; 624 this.player.classList[paused ? 'remove' : 'add']('spkbl-player--paused'); 625 this.controls.pause.setAttribute('aria-pressed', paused ? 'false' : 'true'); 626 return this.paused; 627 }; 628 /** 629 * Stop playing and reset player 630 */ 631 Speakable.prototype.stop = function stop() { 632 this.halt(); 633 this.controls.play.focus(); 634 }; 635 /** 636 * Stop playing 637 */ 638 Speakable.prototype.halt = function halt() { 639 speechUtterance.onboundary = null; 640 speechUtterance.onend = null; 641 speechSynthesis.cancel(); 642 this.togglePause(true); 643 d.removeEventListener('keyup', this.escape.bind(this)); 644 this.player.classList.add('spkbl-player--inactive'); 645 this.player.classList.remove('spkbl-player--active'); 646 this.player.classList.remove('spkbl-player--paused'); 647 }; 648 /** 649 * Inject the player 650 * 651 * @private 652 */ 653 Speakable.prototype.injectPlayer = function injectPlayer() { 654 if (typeof this.options.insert === 'function') { 655 this.options.insert(this.element, this.player); 656 return; 657 } 658 switch (this.options.insert) { 659 case 'before': 660 this.element.parentNode.insertBefore(this.player, this.element); 661 break; 662 case 'after': 663 this.element.parentNode.insertBefore(this.player, this.element.nextSibling); 664 break; 665 default: 666 this.element.insertBefore(this.player, this.element.firstChild); 667 } 668 }; 669 /** 670 * Currently active player 671 * 672 * @type {Speakable} 673 */ 674 Speakable.current = null; 675 /** 676 * Initialize all speakables 677 * 678 * @param {Object} options Options 679 * 680 * @returns {Array} Speakables 681 */ 682 Speakable.init = function init(options) { 683 if (options === void 0) { options = {}; } 684 // If the Web Speech API is supported 685 if ('SpeechSynthesisUtterance' in w) { 686 // Prepare the options 687 var opts_1 = mergeDeep(defaultOptions, options); 688 var selector = opts_1.selector || ''; 689 delete opts_1.selector; 690 // Prepare the voices 691 speechUtterance = new SpeechSynthesisUtterance(); 692 speechUtterance.volume = 1; 693 speechUtterance.pitch = 1; 694 speechUtterance.rate = 1; 695 voices = speechSynthesis.getVoices().filter(function (v) { return !opts_1.local || v.localService; }); 696 // Safari iOS doesn't support the addEventListener() method for the speechSynthesis 697 if (speechSynthesis.addEventListener) { 698 speechSynthesis.addEventListener('voiceschanged', function () { 699 voices = speechSynthesis.getVoices().filter(function (v) { return !opts_1.local || v.localService; }); 700 }); 701 } 702 return selector.length ? Array.from(d.querySelectorAll(selector)) 703 .map(function (s) { return new Speakable(s, opts_1); }) : []; 704 } 705 return []; 706 }; 707 if (typeof exports !== 'undefined') { 708 exports.Speakable = Speakable; 709 } 710 else { 711 w.Speakable = Speakable; 712 } 713}(typeof global !== 'undefined' ? global : window, document));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.