зеркало из https://github.com/mozilla/gecko-dev.git
465 строки
14 KiB
JavaScript
465 строки
14 KiB
JavaScript
/* 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");
|
|
}
|
|
};
|