summaryrefslogtreecommitdiffstats
path: root/toolkit/components/narrate/Narrator.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/narrate/Narrator.jsm')
-rw-r--r--toolkit/components/narrate/Narrator.jsm464
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");
+ }
+};