зеркало из https://github.com/mozilla/gecko-dev.git
457 строки
12 KiB
JavaScript
457 строки
12 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/. */
|
|
|
|
// 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",
|
|
];
|
|
|
|
export function Narrator(win, languagePromise) {
|
|
this._winRef = Cu.getWeakReference(win);
|
|
this._languagePromise = languagePromise;
|
|
this._inTest = Services.prefs.getBoolPref("narrate.test");
|
|
this._speechOptions = {};
|
|
this._startTime = 0;
|
|
this._stopped = false;
|
|
}
|
|
|
|
Narrator.prototype = {
|
|
get _doc() {
|
|
return this._winRef.get().document;
|
|
},
|
|
|
|
get _win() {
|
|
return this._winRef.get();
|
|
},
|
|
|
|
get _treeWalker() {
|
|
if (!this._treeWalkerRef) {
|
|
let wu = this._win.windowUtils;
|
|
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(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.querySelector(".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(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(paragraph) {
|
|
if (!paragraph) {
|
|
return false;
|
|
}
|
|
|
|
let bb = paragraph.getBoundingClientRect();
|
|
return bb.top >= 0 && bb.top < this._win.innerHeight;
|
|
},
|
|
|
|
_sendTestEvent(eventType, detail) {
|
|
let win = this._win;
|
|
win.dispatchEvent(
|
|
new win.CustomEvent(eventType, {
|
|
detail: Cu.cloneInto(detail, win.document),
|
|
})
|
|
);
|
|
},
|
|
|
|
_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");
|
|
},
|
|
};
|