diff options
Diffstat (limited to 'toolkit/components/narrate/Narrator.jsm')
-rw-r--r-- | toolkit/components/narrate/Narrator.jsm | 464 |
1 files changed, 464 insertions, 0 deletions
diff --git a/toolkit/components/narrate/Narrator.jsm b/toolkit/components/narrate/Narrator.jsm new file mode 100644 index 000000000..ade06510e --- /dev/null +++ b/toolkit/components/narrate/Narrator.jsm @@ -0,0 +1,464 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", + "resource:///modules/translation/LanguageDetector.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = [ "Narrator" ]; + +// Maximum time into paragraph when pressing "skip previous" will go +// to previous paragraph and not the start of current one. +const PREV_THRESHOLD = 2000; +// All text-related style rules that we should copy over to the highlight node. +const kTextStylesRules = ["font-family", "font-kerning", "font-size", + "font-size-adjust", "font-stretch", "font-variant", "font-weight", + "line-height", "letter-spacing", "text-orientation", + "text-transform", "word-spacing"]; + +function Narrator(win) { + this._winRef = Cu.getWeakReference(win); + this._inTest = Services.prefs.getBoolPref("narrate.test"); + this._speechOptions = {}; + this._startTime = 0; + this._stopped = false; + + this.languagePromise = new Promise(resolve => { + let detect = () => { + win.document.removeEventListener("AboutReaderContentReady", detect); + let sampleText = this._doc.getElementById( + "moz-reader-content").textContent.substring(0, 60 * 1024); + LanguageDetector.detectLanguage(sampleText).then(result => { + resolve(result.confident ? result.language : null); + }); + }; + + if (win.document.body.classList.contains("loaded")) { + detect(); + } else { + win.document.addEventListener("AboutReaderContentReady", detect); + } + }); +} + +Narrator.prototype = { + get _doc() { + return this._winRef.get().document; + }, + + get _win() { + return this._winRef.get(); + }, + + get _treeWalker() { + if (!this._treeWalkerRef) { + let wu = this._win.QueryInterface( + Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + let nf = this._win.NodeFilter; + + let filter = { + _matches: new Set(), + + // We want high-level elements that have non-empty text nodes. + // For example, paragraphs. But nested anchors and other elements + // are not interesting since their text already appears in their + // parent's textContent. + acceptNode: function(node) { + if (this._matches.has(node.parentNode)) { + // Reject sub-trees of accepted nodes. + return nf.FILTER_REJECT; + } + + if (!/\S/.test(node.textContent)) { + // Reject nodes with no text. + return nf.FILTER_REJECT; + } + + let bb = wu.getBoundsWithoutFlushing(node); + if (!bb.width || !bb.height) { + // Skip non-rendered nodes. We don't reject because a zero-sized + // container can still have visible, "overflowed", content. + return nf.FILTER_SKIP; + } + + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType == c.TEXT_NODE && /\S/.test(c.textContent)) { + // If node has a non-empty text child accept it. + this._matches.add(node); + return nf.FILTER_ACCEPT; + } + } + + return nf.FILTER_SKIP; + } + }; + + this._treeWalkerRef = new WeakMap(); + + // We can't hold a weak reference on the treewalker, because there + // are no other strong references, and it will be GC'ed. Instead, + // we rely on the window's lifetime and use it as a weak reference. + this._treeWalkerRef.set(this._win, + this._doc.createTreeWalker(this._doc.getElementById("container"), + nf.SHOW_ELEMENT, filter, false)); + } + + return this._treeWalkerRef.get(this._win); + }, + + get _timeIntoParagraph() { + let rv = Date.now() - this._startTime; + return rv; + }, + + get speaking() { + return this._win.speechSynthesis.speaking || + this._win.speechSynthesis.pending; + }, + + _getVoice: function(voiceURI) { + if (!this._voiceMap || !this._voiceMap.has(voiceURI)) { + this._voiceMap = new Map( + this._win.speechSynthesis.getVoices().map(v => [v.voiceURI, v])); + } + + return this._voiceMap.get(voiceURI); + }, + + _isParagraphInView: function(paragraph) { + if (!paragraph) { + return false; + } + + let bb = paragraph.getBoundingClientRect(); + return bb.top >= 0 && bb.top < this._win.innerHeight; + }, + + _sendTestEvent: function(eventType, detail) { + let win = this._win; + win.dispatchEvent(new win.CustomEvent(eventType, + { detail: Cu.cloneInto(detail, win.document) })); + }, + + _speakInner: function() { + 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); + 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; + } + + // Match non-whitespace. This isn't perfect, but the most universal + // solution for now. + let reWordBoundary = /\S+/g; + // Match the first word from the boundary event offset. + reWordBoundary.lastIndex = e.charIndex; + let firstIndex = reWordBoundary.exec(paragraph.textContent); + if (firstIndex) { + highlighter.highlight(firstIndex.index, reWordBoundary.lastIndex); + if (this._inTest) { + this._sendTestEvent("wordhighlight", { + start: firstIndex.index, + end: reWordBoundary.lastIndex + }); + } + } + }); + + this._win.speechSynthesis.speak(utterance); + }); + }, + + start: function(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: function() { + this._stopped = true; + this._win.speechSynthesis.cancel(); + }, + + skipNext: function() { + this._win.speechSynthesis.cancel(); + }, + + skipPrevious: function() { + this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1); + }, + + setRate: function(rate) { + this._speechOptions.rate = rate; + /* repeat current paragraph */ + this._goBackParagraphs(1); + }, + + setVoice: function(voice) { + this._speechOptions.voice = this._getVoice(voice); + /* repeat current paragraph */ + this._goBackParagraphs(1); + }, + + _goBackParagraphs: function(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 {nsIDOMElement} 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} endOffset the end offset + */ + highlight: function(startOffset, endOffset) { + let containerRect = this.container.getBoundingClientRect(); + let range = this._getRange(startOffset, endOffset); + let rangeRects = range.getClientRects(); + let win = this.container.ownerDocument.defaultView; + 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: function() { + 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: function(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: function(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"); + } +}; |