}) ); }, _speakInner() { this._win.speechSynthesis.cancel(); let tw = this._treeWalker; let paragraph = tw.currentNode; if (paragraph == tw.root) { this._sendTestEvent("paragraphsdone", {}); return Promise.resolve(); } let utterance = new this._win.SpeechSynthesisUtterance( paragraph.textContent.replace(/\r?\n/g, " ") ); utterance.rate = this._speechOptions.rate; if (this._speechOptions.voice) { utterance.voice = this._speechOptions.voice; } else { utterance.lang = this._speechOptions.lang; } this._startTime = Date.now(); let highlighter = new Highlighter(paragraph); if (this._inTest) { let onTestSynthEvent = e => { if (e.detail.type == "boundary") { let args = Object.assign({ utterance }, e.detail.args); let evt = new this._win.SpeechSynthesisEvent(e.detail.type, args); utterance.dispatchEvent(evt); } }; let removeListeners = () => { this._win.removeEventListener("testsynthevent", onTestSynthEvent); }; this._win.addEventListener("testsynthevent", onTestSynthEvent); utterance.addEventListener("end", removeListeners); utterance.addEventListener("error", removeListeners); } return new Promise((resolve, reject) => { utterance.addEventListener("start", () => { paragraph.classList.add("narrating"); let bb = paragraph.getBoundingClientRect(); if (bb.top < 0 || bb.bottom > this._win.innerHeight) { paragraph.scrollIntoView({ behavior: "smooth", block: "start" }); } if (this._inTest) { this._sendTestEvent("paragraphstart", { voice: utterance.chosenVoiceURI, rate: utterance.rate, paragraph: paragraph.textContent, tag: paragraph.localName, }); } }); utterance.addEventListener("end", () => { if (!this._win) { // page got unloaded, don't do anything. return; } highlighter.remove(); paragraph.classList.remove("narrating"); this._startTime = 0; if (this._inTest) { this._sendTestEvent("paragraphend", {}); } if (this._stopped) { // User pressed stopped. resolve(); } else { tw.currentNode = tw.nextNode() || tw.root; this._speakInner().then(resolve, reject); } }); utterance.addEventListener("error", () => { reject("speech synthesis failed"); }); utterance.addEventListener("boundary", e => { if (e.name != "word") { // We are only interested in word boundaries for now. return; } if (e.charLength) { highlighter.highlight(e.charIndex, e.charLength); if (this._inTest) { this._sendTestEvent("wordhighlight", { start: e.charIndex, end: e.charIndex + e.charLength, }); } } }); this._win.speechSynthesis.speak(utterance); }); }, start(speechOptions) { this._speechOptions = { rate: speechOptions.rate, voice: this._getVoice(speechOptions.voice), }; this._stopped = false; return this._languagePromise.then(language => { if (!this._speechOptions.voice) { this._speechOptions.lang = language; } let tw = this._treeWalker; if (!this._isParagraphInView(tw.currentNode)) { tw.currentNode = tw.root; while (tw.nextNode()) { if (this._isParagraphInView(tw.currentNode)) { break; } } } if (tw.currentNode == tw.root) { tw.nextNode(); } return this._speakInner(); }); }, stop() { this._stopped = true; this._win.speechSynthesis.cancel(); }, skipNext() { this._win.speechSynthesis.cancel(); }, skipPrevious() { this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1); }, setRate(rate) { this._speechOptions.rate = rate; /* repeat current paragraph */ this._goBackParagraphs(1); }, setVoice(voice) { this._speechOptions.voice = this._getVoice(voice); /* repeat current paragraph */ this._goBackParagraphs(1); }, _goBackParagraphs(count) { let tw = this._treeWalker; for (let i = 0; i < count; i++) { if (!tw.previousNode()) { tw.currentNode = tw.root; } } this._win.speechSynthesis.cancel(); }, }; /** * The Highlighter class is used to highlight a range of text in a container. * * @param {Element} container a text container */ function Highlighter(container) { this.container = container; } Highlighter.prototype = { /** * Highlight the range within offsets relative to the container. * * @param {number} startOffset the start offset * @param {number} length the length in characters of the range */ highlight(startOffset, length) { let containerRect = this.container.getBoundingClientRect(); let range = this._getRange(startOffset, startOffset + length); let rangeRects = range.getClientRects(); let win = this.container.ownerGlobal; let computedStyle = win.getComputedStyle(range.endContainer.parentNode); let nodes = this._getFreshHighlightNodes(rangeRects.length); let textStyle = {}; for (let textStyleRule of kTextStylesRules) { textStyle[textStyleRule] = computedStyle[textStyleRule]; } for (let i = 0; i < rangeRects.length; i++) { let r = rangeRects[i]; let node = nodes[i]; let style = Object.assign( { top: `${r.top - containerRect.top + r.height / 2}px`, left: `${r.left - containerRect.left + r.width / 2}px`, width: `${r.width}px`, height: `${r.height}px`, }, textStyle ); // Enables us to vary the CSS transition on a line change. node.classList.toggle("newline", style.top != node.dataset.top); node.dataset.top = style.top; // Enables CSS animations. node.classList.remove("animate"); win.requestAnimationFrame(() => { node.classList.add("animate"); }); // Enables alternative word display with a CSS pseudo-element. node.dataset.word = range.toString(); // Apply style node.style = Object.entries(style) .map(s => `${s[0]}: ${s[1]};`) .join(" "); } }, /** * Releases reference to container and removes all highlight nodes. */ remove() { for (let node of this._nodes) { node.remove(); } this.container = null; }, /** * Returns specified amount of highlight nodes. Creates new ones if necessary * and purges any additional nodes that are not needed. * * @param {number} count number of nodes needed */ _getFreshHighlightNodes(count) { let doc = this.container.ownerDocument; let nodes = Array.from(this._nodes); // Remove nodes we don't need anymore (nodes.length - count > 0). for (let toRemove = 0; toRemove < nodes.length - count; toRemove++) { nodes.shift().remove(); } // Add additional nodes if we need them (count - nodes.length > 0). for (let toAdd = 0; toAdd < count - nodes.length; toAdd++) { let node = doc.createElement("div"); node.className = "narrate-word-highlight"; this.container.appendChild(node); nodes.push(node); } return nodes; }, /** * Create and return a range object with the start and end offsets relative * to the container node. * * @param {number} startOffset the start offset * @param {number} endOffset the end offset */ _getRange(startOffset, endOffset) { let doc = this.container.ownerDocument; let i = 0; let treeWalker = doc.createTreeWalker( this.container, doc.defaultView.NodeFilter.SHOW_TEXT ); let node = treeWalker.nextNode(); function _findNodeAndOffset(offset) { do { let length = node.data.length; if (offset >= i && offset <= i + length) { return [node, offset - i]; } i += length; } while ((node = treeWalker.nextNode())); // Offset is out of bounds, return last offset of last node. node = treeWalker.lastChild(); return [node, node.data.length]; } let range = doc.createRange(); range.setStart(..._findNodeAndOffset(startOffset)); range.setEnd(..._findNodeAndOffset(endOffset)); return range; }, /* * Get all existing highlight nodes for container. */ get _nodes() { return this.container.querySelectorAll(".narrate-word-highlight"); }, }; PK