summaryrefslogtreecommitdiff
path: root/components/narrate
diff options
context:
space:
mode:
authorMatt A. Tobin <email@mattatobin.com>2021-11-14 01:30:12 -0500
committerMatt A. Tobin <email@mattatobin.com>2021-11-14 01:30:12 -0500
commit43fa0a71c814e902165782680f2f9705e3c234c9 (patch)
tree0ab3c66e8432febc00c18c036706c7ff43c11799 /components/narrate
parent2fccdffac3075ee72087f9ee153204b02c7d931a (diff)
downloadaura-central-43fa0a71c814e902165782680f2f9705e3c234c9.tar.gz
Issue %3005 - Move toolkit/components to components/
Diffstat (limited to 'components/narrate')
-rw-r--r--components/narrate/.eslintrc.js94
-rw-r--r--components/narrate/NarrateControls.jsm313
-rw-r--r--components/narrate/Narrator.jsm440
-rw-r--r--components/narrate/VoiceSelect.jsm300
-rw-r--r--components/narrate/moz.build10
5 files changed, 1157 insertions, 0 deletions
diff --git a/components/narrate/.eslintrc.js b/components/narrate/.eslintrc.js
new file mode 100644
index 000000000..b2d443575
--- /dev/null
+++ b/components/narrate/.eslintrc.js
@@ -0,0 +1,94 @@
+"use strict";
+
+module.exports = { // eslint-disable-line no-undef
+ "extends": [
+ "../../.eslintrc.js"
+ ],
+
+ "globals": {
+ "Components": true,
+ "dump": true,
+ "Iterator": true
+ },
+
+ "env": { "browser": true },
+
+ "rules": {
+ // Mozilla stuff
+ "mozilla/no-aArgs": "warn",
+ "mozilla/reject-importGlobalProperties": "warn",
+ "mozilla/var-only-at-top-level": "warn",
+
+ "block-scoped-var": "error",
+ "brace-style": ["warn", "1tbs", {"allowSingleLine": false}],
+ "camelcase": "warn",
+ "comma-dangle": "off",
+ "comma-spacing": ["warn", {"before": false, "after": true}],
+ "comma-style": ["warn", "last"],
+ "complexity": "warn",
+ "consistent-return": "error",
+ "curly": "error",
+ "dot-location": ["warn", "property"],
+ "dot-notation": "error",
+ "eol-last": "error",
+ "generator-star-spacing": ["warn", "after"],
+ "indent": ["warn", 2, {"SwitchCase": 1}],
+ "key-spacing": ["warn", {"beforeColon": false, "afterColon": true}],
+ "keyword-spacing": "warn",
+ "max-len": ["warn", 80, 2, {"ignoreUrls": true}],
+ "max-nested-callbacks": ["error", 3],
+ "new-cap": ["error", {"capIsNew": false}],
+ "new-parens": "error",
+ "no-array-constructor": "error",
+ "no-cond-assign": "error",
+ "no-control-regex": "error",
+ "no-debugger": "error",
+ "no-delete-var": "error",
+ "no-dupe-args": "error",
+ "no-dupe-keys": "error",
+ "no-duplicate-case": "error",
+ "no-else-return": "error",
+ "no-eval": "error",
+ "no-extend-native": "error",
+ "no-extra-bind": "error",
+ "no-extra-boolean-cast": "error",
+ "no-extra-semi": "warn",
+ "no-fallthrough": "error",
+ "no-inline-comments": "warn",
+ "no-lonely-if": "error",
+ "no-mixed-spaces-and-tabs": "error",
+ "no-multi-spaces": "warn",
+ "no-multi-str": "warn",
+ "no-multiple-empty-lines": ["warn", {"max": 1}],
+ "no-native-reassign": "error",
+ "no-nested-ternary": "error",
+ "no-redeclare": "error",
+ "no-return-assign": "error",
+ "no-self-compare": "error",
+ "no-sequences": "error",
+ "no-shadow": "warn",
+ "no-shadow-restricted-names": "error",
+ "no-spaced-func": "warn",
+ "no-throw-literal": "error",
+ "no-trailing-spaces": "error",
+ "no-undef": "error",
+ "no-unneeded-ternary": "error",
+ "no-unreachable": "error",
+ "no-unused-vars": "error",
+ "no-with": "error",
+ "padded-blocks": ["warn", "never"],
+ "quotes": ["warn", "double", "avoid-escape"],
+ "semi": ["warn", "always"],
+ "semi-spacing": ["warn", {"before": false, "after": true}],
+ "space-before-blocks": ["warn", "always"],
+ "space-before-function-paren": ["warn", "never"],
+ "space-in-parens": ["warn", "never"],
+ "space-infix-ops": ["warn", {"int32Hint": true}],
+ "space-unary-ops": ["warn", { "words": true, "nonwords": false }],
+ "spaced-comment": ["warn", "always"],
+ "strict": ["error", "global"],
+ "use-isnan": "error",
+ "valid-typeof": "error",
+ "yoda": "error"
+ }
+};
diff --git a/components/narrate/NarrateControls.jsm b/components/narrate/NarrateControls.jsm
new file mode 100644
index 000000000..56b3deaf8
--- /dev/null
+++ b/components/narrate/NarrateControls.jsm
@@ -0,0 +1,313 @@
+/* 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 Cu = Components.utils;
+
+Cu.import("resource://gre/modules/narrate/VoiceSelect.jsm");
+Cu.import("resource://gre/modules/narrate/Narrator.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/AsyncPrefs.jsm");
+
+this.EXPORTED_SYMBOLS = ["NarrateControls"];
+
+var gStrings = Services.strings.createBundle("chrome://global/locale/narrate.properties");
+
+function NarrateControls(win, languagePromise) {
+ this._winRef = Cu.getWeakReference(win);
+ this._languagePromise = languagePromise;
+
+ win.addEventListener("unload", this);
+
+ // Append content style sheet in document head
+ let style = win.document.createElement("link");
+ style.rel = "stylesheet";
+ style.href = "chrome://global/skin/narrate.css";
+ win.document.head.appendChild(style);
+
+ function localize(pieces, ...substitutions) {
+ let result = pieces[0];
+ for (let i = 0; i < substitutions.length; ++i) {
+ result += gStrings.GetStringFromName(substitutions[i]) + pieces[i + 1];
+ }
+ return result;
+ }
+
+ let dropdown = win.document.createElement("ul");
+ dropdown.className = "dropdown narrate-dropdown";
+ // We need inline svg here for the animation to work (bug 908634 & 1190881).
+ // eslint-disable-next-line no-unsanitized/property
+ dropdown.innerHTML =
+ localize`<li>
+ <button class="dropdown-toggle button narrate-toggle"
+ title="${"narrate"}" hidden>
+ <svg xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="24" height="24" viewBox="0 0 24 24">
+ <style>
+ @keyframes grow {
+ 0% { transform: scaleY(1); }
+ 15% { transform: scaleY(1.5); }
+ 15% { transform: scaleY(1.5); }
+ 30% { transform: scaleY(1); }
+ 100% { transform: scaleY(1); }
+ }
+
+ .waveform > rect {
+ fill: #808080;
+ }
+
+ .speaking .waveform > rect {
+ fill: #58bf43;
+ transform-box: fill-box;
+ transform-origin: 50% 50%;
+ animation-name: grow;
+ animation-duration: 1750ms;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+ }
+
+ .waveform > rect:nth-child(2) { animation-delay: 250ms; }
+ .waveform > rect:nth-child(3) { animation-delay: 500ms; }
+ .waveform > rect:nth-child(4) { animation-delay: 750ms; }
+ .waveform > rect:nth-child(5) { animation-delay: 1000ms; }
+ .waveform > rect:nth-child(6) { animation-delay: 1250ms; }
+ .waveform > rect:nth-child(7) { animation-delay: 1500ms; }
+
+ </style>
+ <g class="waveform">
+ <rect x="1" y="8" width="2" height="8" rx=".5" ry=".5" />
+ <rect x="4" y="5" width="2" height="14" rx=".5" ry=".5" />
+ <rect x="7" y="8" width="2" height="8" rx=".5" ry=".5" />
+ <rect x="10" y="4" width="2" height="16" rx=".5" ry=".5" />
+ <rect x="13" y="2" width="2" height="20" rx=".5" ry=".5" />
+ <rect x="16" y="4" width="2" height="16" rx=".5" ry=".5" />
+ <rect x="19" y="7" width="2" height="10" rx=".5" ry=".5" />
+ </g>
+ </svg>
+ </button>
+ </li>
+ <li class="dropdown-popup">
+ <div class="narrate-row narrate-control">
+ <button disabled class="narrate-skip-previous"
+ title="${"back"}"></button>
+ <button class="narrate-start-stop" title="${"start"}"></button>
+ <button disabled class="narrate-skip-next"
+ title="${"forward"}"></button>
+ </div>
+ <div class="narrate-row narrate-rate">
+ <input class="narrate-rate-input" value="0" title="${"speed"}"
+ step="5" max="100" min="-100" type="range">
+ </div>
+ <div class="narrate-row narrate-voices"></div>
+ <div class="dropdown-arrow"></div>
+ </li>`;
+
+ this.narrator = new Narrator(win, languagePromise);
+
+ let branch = Services.prefs.getBranch("narrate.");
+ let selectLabel = gStrings.GetStringFromName("selectvoicelabel");
+ this.voiceSelect = new VoiceSelect(win, selectLabel);
+ this.voiceSelect.element.addEventListener("change", this);
+ this.voiceSelect.element.classList.add("voice-select");
+ win.speechSynthesis.addEventListener("voiceschanged", this);
+ dropdown.querySelector(".narrate-voices").appendChild(
+ this.voiceSelect.element);
+
+ dropdown.addEventListener("click", this, true);
+
+ let rateRange = dropdown.querySelector(".narrate-rate > input");
+ rateRange.addEventListener("change", this);
+
+ // The rate is stored as an integer.
+ rateRange.value = branch.getIntPref("rate");
+
+ this._setupVoices();
+
+ let tb = win.document.querySelector(".reader-toolbar");
+ tb.appendChild(dropdown);
+}
+
+NarrateControls.prototype = {
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "change":
+ if (evt.target.classList.contains("narrate-rate-input")) {
+ this._onRateInput(evt);
+ } else {
+ this._onVoiceChange();
+ }
+ break;
+ case "click":
+ this._onButtonClick(evt);
+ break;
+ case "voiceschanged":
+ this._setupVoices();
+ break;
+ }
+ },
+
+ /**
+ * Returns true if synth voices are available.
+ */
+ _setupVoices() {
+ return this._languagePromise.then(language => {
+ this.voiceSelect.clear();
+ let win = this._win;
+ let voicePrefs = this._getVoicePref();
+ let selectedVoice = voicePrefs[language || "default"];
+ let comparer = win.Intl ?
+ (new Intl.Collator()).compare : (a, b) => a.localeCompare(b);
+ let filter = !Services.prefs.getBoolPref("narrate.filter-voices");
+ let options = win.speechSynthesis.getVoices().filter(v => {
+ return filter || !language || v.lang.split("-")[0] == language;
+ }).map(v => {
+ return {
+ label: this._createVoiceLabel(v),
+ value: v.voiceURI,
+ selected: selectedVoice == v.voiceURI
+ };
+ }).sort((a, b) => comparer(a.label, b.label));
+
+ if (options.length) {
+ options.unshift({
+ label: gStrings.GetStringFromName("defaultvoice"),
+ value: "automatic",
+ selected: selectedVoice == "automatic"
+ });
+ this.voiceSelect.addOptions(options);
+ }
+
+ let narrateToggle = win.document.querySelector(".narrate-toggle");
+ let initial = !this._voicesInitialized;
+ this._voicesInitialized = true;
+
+ // We disable this entire feature if there are no available voices.
+ narrateToggle.hidden = !options.length;
+ });
+ },
+
+ _getVoicePref() {
+ let voicePref = Services.prefs.getCharPref("narrate.voice");
+ try {
+ return JSON.parse(voicePref);
+ } catch (e) {
+ return { default: voicePref };
+ }
+ },
+
+ _onRateInput(evt) {
+ AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10));
+ this.narrator.setRate(this._convertRate(evt.target.value));
+ },
+
+ _onVoiceChange() {
+ let voice = this.voice;
+ this.narrator.setVoice(voice);
+ this._languagePromise.then(language => {
+ if (language) {
+ let voicePref = this._getVoicePref();
+ voicePref[language || "default"] = voice;
+ AsyncPrefs.set("narrate.voice", JSON.stringify(voicePref));
+ }
+ });
+ },
+
+ _onButtonClick(evt) {
+ let classList = evt.target.classList;
+ if (classList.contains("narrate-skip-previous")) {
+ this.narrator.skipPrevious();
+ } else if (classList.contains("narrate-skip-next")) {
+ this.narrator.skipNext();
+ } else if (classList.contains("narrate-start-stop")) {
+ if (this.narrator.speaking) {
+ this.narrator.stop();
+ } else {
+ this._updateSpeechControls(true);
+ let options = { rate: this.rate, voice: this.voice };
+ this.narrator.start(options).then(() => {
+ this._updateSpeechControls(false);
+ }, err => {
+ Cu.reportError(`Narrate failed: ${err}.`);
+ this._updateSpeechControls(false);
+ });
+ }
+ }
+ },
+
+ _updateSpeechControls(speaking) {
+ let dropdown = this._doc.querySelector(".narrate-dropdown");
+ dropdown.classList.toggle("keep-open", speaking);
+ dropdown.classList.toggle("speaking", speaking);
+
+ let startStopButton = this._doc.querySelector(".narrate-start-stop");
+ startStopButton.title =
+ gStrings.GetStringFromName(speaking ? "stop" : "start");
+
+ this._doc.querySelector(".narrate-skip-previous").disabled = !speaking;
+ this._doc.querySelector(".narrate-skip-next").disabled = !speaking;
+ },
+
+ _createVoiceLabel(voice) {
+ // This is a highly imperfect method of making human-readable labels
+ // for system voices. Because each platform has a different naming scheme
+ // for voices, we use a different method for each platform.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // On windows the language is included in the name, so just use the name
+ return voice.name;
+ case "Linux":
+ // On Linux, the name is usually the unlocalized language name.
+ // Use a localized language name, and have the language tag in
+ // parenthisis. This is to avoid six languages called "English".
+ return gStrings.formatStringFromName("voiceLabel",
+ [this._getLanguageName(voice.lang) || voice.name, voice.lang], 2);
+ default:
+ // On Mac the language is not included in the name, find a localized
+ // language name or show the tag if none exists.
+ // This is the ideal naming scheme so it is also the "default".
+ return gStrings.formatStringFromName("voiceLabel",
+ [voice.name, this._getLanguageName(voice.lang) || voice.lang], 2);
+ }
+ },
+
+ _getLanguageName(lang) {
+ if (!this._langStrings) {
+ this._langStrings = Services.strings.createBundle(
+ "chrome://global/locale/languageNames.properties ");
+ }
+
+ try {
+ // language tags will be lower case ascii between 2 and 3 characters long.
+ return this._langStrings.GetStringFromName(lang.match(/^[a-z]{2,3}/)[0]);
+ } catch (e) {
+ return "";
+ }
+ },
+
+ _convertRate(rate) {
+ // We need to convert a relative percentage value to a fraction rate value.
+ // eg. -100 is half the speed, 100 is twice the speed in percentage,
+ // 0.5 is half the speed and 2 is twice the speed in fractions.
+ return Math.pow(Math.abs(rate / 100) + 1, rate < 0 ? -1 : 1);
+ },
+
+ get _win() {
+ return this._winRef.get();
+ },
+
+ get _doc() {
+ return this._win.document;
+ },
+
+ get rate() {
+ return this._convertRate(
+ this._doc.querySelector(".narrate-rate-input").value);
+ },
+
+ get voice() {
+ return this.voiceSelect.value;
+ }
+};
diff --git a/components/narrate/Narrator.jsm b/components/narrate/Narrator.jsm
new file mode 100644
index 000000000..ac0b2e040
--- /dev/null
+++ b/components/narrate/Narrator.jsm
@@ -0,0 +1,440 @@
+/* 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, "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, 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.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(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);
+ 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 {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} 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");
+ }
+};
diff --git a/components/narrate/VoiceSelect.jsm b/components/narrate/VoiceSelect.jsm
new file mode 100644
index 000000000..861a21c97
--- /dev/null
+++ b/components/narrate/VoiceSelect.jsm
@@ -0,0 +1,300 @@
+/* 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 Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = ["VoiceSelect"];
+
+function VoiceSelect(win, label) {
+ this._winRef = Cu.getWeakReference(win);
+
+ let element = win.document.createElement("div");
+ element.classList.add("voiceselect");
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML =
+ `<button class="select-toggle" aria-controls="voice-options">
+ <span class="label">${label}</span> <span class="current-voice"></span>
+ </button>
+ <div class="options" id="voice-options" role="listbox"></div>`;
+
+ this._elementRef = Cu.getWeakReference(element);
+
+ let button = this.selectToggle;
+ button.addEventListener("click", this);
+ button.addEventListener("keypress", this);
+
+ let listbox = this.listbox;
+ listbox.addEventListener("click", this);
+ listbox.addEventListener("mousemove", this);
+ listbox.addEventListener("keypress", this);
+ listbox.addEventListener("wheel", this, true);
+
+ win.addEventListener("resize", () => {
+ this._updateDropdownHeight();
+ });
+}
+
+VoiceSelect.prototype = {
+ add(label, value) {
+ let option = this._doc.createElement("button");
+ option.dataset.value = value;
+ option.classList.add("option");
+ option.tabIndex = "-1";
+ option.setAttribute("role", "option");
+ option.textContent = label;
+ this.listbox.appendChild(option);
+ return option;
+ },
+
+ addOptions(options) {
+ let selected = null;
+ for (let option of options) {
+ if (option.selected) {
+ selected = this.add(option.label, option.value);
+ } else {
+ this.add(option.label, option.value);
+ }
+ }
+
+ this._select(selected || this.options[0], true);
+ },
+
+ clear() {
+ this.listbox.innerHTML = "";
+ },
+
+ toggleList(force, focus = true) {
+ if (this.element.classList.toggle("open", force)) {
+ if (focus) {
+ (this.selected || this.options[0]).focus();
+ }
+
+ this._updateDropdownHeight(true);
+ this.listbox.setAttribute("aria-expanded", true);
+ this._win.addEventListener("focus", this, true);
+ } else {
+ if (focus) {
+ this.element.querySelector(".select-toggle").focus();
+ }
+
+ this.listbox.setAttribute("aria-expanded", false);
+ this._win.removeEventListener("focus", this, true);
+ }
+ },
+
+ handleEvent(evt) {
+ let target = evt.target;
+
+ switch (evt.type) {
+ case "click":
+ if (target.classList.contains("option")) {
+ if (!target.classList.contains("selected")) {
+ this.selected = target;
+ }
+
+ this.toggleList(false);
+ } else if (target.classList.contains("select-toggle")) {
+ this.toggleList();
+ }
+ break;
+
+ case "mousemove":
+ this.listbox.classList.add("hovering");
+ break;
+
+ case "keypress":
+ if (target.classList.contains("select-toggle")) {
+ if (evt.altKey) {
+ this.toggleList(true);
+ } else {
+ this._keyPressedButton(evt);
+ }
+ } else {
+ this.listbox.classList.remove("hovering");
+ this._keyPressedInBox(evt);
+ }
+ break;
+
+ case "wheel":
+ // Don't let wheel events bubble to document. It will scroll the page
+ // and close the entire narrate dialog.
+ evt.stopPropagation();
+ break;
+
+ case "focus":
+ if (!target.closest(".voiceselect")) {
+ this.toggleList(false, false);
+ }
+ break;
+ }
+ },
+
+ _getPagedOption(option, up) {
+ let height = elem => elem.getBoundingClientRect().height;
+ let listboxHeight = height(this.listbox);
+
+ let next = option;
+ for (let delta = 0; delta < listboxHeight; delta += height(next)) {
+ let sibling = up ? next.previousElementSibling : next.nextElementSibling;
+ if (!sibling) {
+ break;
+ }
+
+ next = sibling;
+ }
+
+ return next;
+ },
+
+ _keyPressedButton(evt) {
+ if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) {
+ this.toggleList(true);
+ return;
+ }
+
+ let toSelect;
+ switch (evt.key) {
+ case "PageUp":
+ case "ArrowUp":
+ toSelect = this.selected.previousElementSibling;
+ break;
+ case "PageDown":
+ case "ArrowDown":
+ toSelect = this.selected.nextElementSibling;
+ break;
+ case "Home":
+ toSelect = this.selected.parentNode.firstElementChild;
+ break;
+ case "End":
+ toSelect = this.selected.parentNode.lastElementChild;
+ break;
+ }
+
+ if (toSelect && toSelect.classList.contains("option")) {
+ evt.preventDefault();
+ this.selected = toSelect;
+ }
+ },
+
+ _keyPressedInBox(evt) {
+ let toFocus;
+ let cur = this._doc.activeElement;
+
+ switch (evt.key) {
+ case "ArrowUp":
+ toFocus = cur.previousElementSibling || this.listbox.lastElementChild;
+ break;
+ case "ArrowDown":
+ toFocus = cur.nextElementSibling || this.listbox.firstElementChild;
+ break;
+ case "PageUp":
+ toFocus = this._getPagedOption(cur, true);
+ break;
+ case "PageDown":
+ toFocus = this._getPagedOption(cur, false);
+ break;
+ case "Home":
+ toFocus = cur.parentNode.firstElementChild;
+ break;
+ case "End":
+ toFocus = cur.parentNode.lastElementChild;
+ break;
+ case "Escape":
+ this.toggleList(false);
+ break;
+ }
+
+ if (toFocus && toFocus.classList.contains("option")) {
+ evt.preventDefault();
+ toFocus.focus();
+ }
+ },
+
+ _select(option, suppressEvent = false) {
+ let oldSelected = this.selected;
+ if (oldSelected) {
+ oldSelected.removeAttribute("aria-selected");
+ oldSelected.classList.remove("selected");
+ }
+
+ if (option) {
+ option.setAttribute("aria-selected", true);
+ option.classList.add("selected");
+ this.element.querySelector(".current-voice").textContent =
+ option.textContent;
+ }
+
+ if (!suppressEvent) {
+ let evt = this.element.ownerDocument.createEvent("Event");
+ evt.initEvent("change", true, true);
+ this.element.dispatchEvent(evt);
+ }
+ },
+
+ _updateDropdownHeight(now) {
+ let updateInner = () => {
+ let winHeight = this._win.innerHeight;
+ let listbox = this.listbox;
+ let listboxTop = listbox.getBoundingClientRect().top;
+ listbox.style.maxHeight = (winHeight - listboxTop - 10) + "px";
+ };
+
+ if (now) {
+ updateInner();
+ } else if (!this._pendingDropdownUpdate) {
+ this._pendingDropdownUpdate = true;
+ this._win.requestAnimationFrame(() => {
+ updateInner();
+ delete this._pendingDropdownUpdate;
+ });
+ }
+ },
+
+ _getOptionFromValue(value) {
+ return Array.from(this.options).find(o => o.dataset.value === value);
+ },
+
+ get element() {
+ return this._elementRef.get();
+ },
+
+ get listbox() {
+ return this._elementRef.get().querySelector(".options");
+ },
+
+ get selectToggle() {
+ return this._elementRef.get().querySelector(".select-toggle");
+ },
+
+ get _win() {
+ return this._winRef.get();
+ },
+
+ get _doc() {
+ return this._win.document;
+ },
+
+ set selected(option) {
+ this._select(option);
+ },
+
+ get selected() {
+ return this.element.querySelector(".options > .option.selected");
+ },
+
+ get options() {
+ return this.element.querySelectorAll(".options > .option");
+ },
+
+ set value(value) {
+ this._select(this._getOptionFromValue(value));
+ },
+
+ get value() {
+ let selected = this.selected;
+ return selected ? selected.dataset.value : "";
+ }
+};
diff --git a/components/narrate/moz.build b/components/narrate/moz.build
new file mode 100644
index 000000000..fe1dfb6ca
--- /dev/null
+++ b/components/narrate/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/.
+
+EXTRA_JS_MODULES.narrate = [
+ 'NarrateControls.jsm',
+ 'Narrator.jsm',
+ 'VoiceSelect.jsm'
+]