summaryrefslogtreecommitdiffstats
path: root/toolkit/components/narrate
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/narrate')
-rw-r--r--toolkit/components/narrate/.eslintrc.js94
-rw-r--r--toolkit/components/narrate/NarrateControls.jsm343
-rw-r--r--toolkit/components/narrate/Narrator.jsm464
-rw-r--r--toolkit/components/narrate/VoiceSelect.jsm299
-rw-r--r--toolkit/components/narrate/moz.build13
-rw-r--r--toolkit/components/narrate/test/.eslintrc.js23
-rw-r--r--toolkit/components/narrate/test/NarrateTestUtils.jsm148
-rw-r--r--toolkit/components/narrate/test/browser.ini12
-rw-r--r--toolkit/components/narrate/test/browser_narrate.js137
-rw-r--r--toolkit/components/narrate/test/browser_narrate_disable.js37
-rw-r--r--toolkit/components/narrate/test/browser_narrate_language.js73
-rw-r--r--toolkit/components/narrate/test/browser_voiceselect.js112
-rw-r--r--toolkit/components/narrate/test/browser_word_highlight.js69
-rw-r--r--toolkit/components/narrate/test/head.js87
-rw-r--r--toolkit/components/narrate/test/inferno.html238
-rw-r--r--toolkit/components/narrate/test/moby_dick.html218
16 files changed, 2367 insertions, 0 deletions
diff --git a/toolkit/components/narrate/.eslintrc.js b/toolkit/components/narrate/.eslintrc.js
new file mode 100644
index 000000000..b2d443575
--- /dev/null
+++ b/toolkit/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/toolkit/components/narrate/NarrateControls.jsm b/toolkit/components/narrate/NarrateControls.jsm
new file mode 100644
index 000000000..7d8794b18
--- /dev/null
+++ b/toolkit/components/narrate/NarrateControls.jsm
@@ -0,0 +1,343 @@
+/* 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");
+Cu.import("resource://gre/modules/TelemetryStopwatch.jsm");
+
+this.EXPORTED_SYMBOLS = ["NarrateControls"];
+
+var gStrings = Services.strings.createBundle("chrome://global/locale/narrate.properties");
+
+function NarrateControls(mm, win) {
+ this._mm = mm;
+ this._winRef = Cu.getWeakReference(win);
+
+ 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";
+ dropdown.id = "narrate-dropdown";
+ // We need inline svg here for the animation to work (bug 908634 & 1190881).
+ // The style animation can't be scoped (bug 830056).
+ dropdown.innerHTML =
+ localize`<style scoped>
+ @import url("chrome://global/skin/narrateControls.css");
+ </style>
+ <li>
+ <button class="dropdown-toggle button" id="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 id="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 id="narrate-control" class="narrate-row">
+ <button disabled id="narrate-skip-previous"
+ title="${"back"}"></button>
+ <button id="narrate-start-stop" title="${"start"}"></button>
+ <button disabled id="narrate-skip-next"
+ title="${"forward"}"></button>
+ </div>
+ <div id="narrate-rate" class="narrate-row">
+ <input id="narrate-rate-input" value="0" title="${"speed"}"
+ step="5" max="100" min="-100" type="range">
+ </div>
+ <div id="narrate-voices" class="narrate-row"></div>
+ <div class="dropdown-arrow"></div>
+ </li>`;
+
+ this.narrator = new Narrator(win);
+
+ 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.id = "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.getElementById("reader-toolbar");
+ tb.appendChild(dropdown);
+}
+
+NarrateControls.prototype = {
+ handleEvent: function(evt) {
+ switch (evt.type) {
+ case "change":
+ if (evt.target.id == "narrate-rate-input") {
+ this._onRateInput(evt);
+ } else {
+ this._onVoiceChange();
+ }
+ break;
+ case "click":
+ this._onButtonClick(evt);
+ break;
+ case "voiceschanged":
+ this._setupVoices();
+ break;
+ case "unload":
+ if (this.narrator.speaking) {
+ TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Returns true if synth voices are available.
+ */
+ _setupVoices: function() {
+ return this.narrator.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.getElementById("narrate-toggle");
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ "NARRATE_CONTENT_BY_LANGUAGE_2");
+ let initial = !this._voicesInitialized;
+ this._voicesInitialized = true;
+
+ if (initial) {
+ histogram.add(language, 0);
+ }
+
+ if (options.length && narrateToggle.hidden) {
+ // About to show for the first time..
+ histogram.add(language, 1);
+ }
+
+ // We disable this entire feature if there are no available voices.
+ narrateToggle.hidden = !options.length;
+ });
+ },
+
+ _getVoicePref: function() {
+ let voicePref = Services.prefs.getCharPref("narrate.voice");
+ try {
+ return JSON.parse(voicePref);
+ } catch (e) {
+ return { default: voicePref };
+ }
+ },
+
+ _onRateInput: function(evt) {
+ AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10));
+ this.narrator.setRate(this._convertRate(evt.target.value));
+ },
+
+ _onVoiceChange: function() {
+ let voice = this.voice;
+ this.narrator.setVoice(voice);
+ this.narrator.languagePromise.then(language => {
+ if (language) {
+ let voicePref = this._getVoicePref();
+ voicePref[language || "default"] = voice;
+ AsyncPrefs.set("narrate.voice", JSON.stringify(voicePref));
+ }
+ });
+ },
+
+ _onButtonClick: function(evt) {
+ switch (evt.target.id) {
+ case "narrate-skip-previous":
+ this.narrator.skipPrevious();
+ break;
+ case "narrate-skip-next":
+ this.narrator.skipNext();
+ break;
+ case "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);
+ });
+ }
+ break;
+ }
+ },
+
+ _updateSpeechControls: function(speaking) {
+ let dropdown = this._doc.getElementById("narrate-dropdown");
+ dropdown.classList.toggle("keep-open", speaking);
+ dropdown.classList.toggle("speaking", speaking);
+
+ let startStopButton = this._doc.getElementById("narrate-start-stop");
+ startStopButton.title =
+ gStrings.GetStringFromName(speaking ? "stop" : "start");
+
+ this._doc.getElementById("narrate-skip-previous").disabled = !speaking;
+ this._doc.getElementById("narrate-skip-next").disabled = !speaking;
+
+ if (speaking) {
+ TelemetryStopwatch.start("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ } else {
+ TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this);
+ }
+ },
+
+ _createVoiceLabel: function(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: function(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: function(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.getElementById("narrate-rate-input").value);
+ },
+
+ get voice() {
+ return this.voiceSelect.value;
+ }
+};
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");
+ }
+};
diff --git a/toolkit/components/narrate/VoiceSelect.jsm b/toolkit/components/narrate/VoiceSelect.jsm
new file mode 100644
index 000000000..b283a06b3
--- /dev/null
+++ b/toolkit/components/narrate/VoiceSelect.jsm
@@ -0,0 +1,299 @@
+/* 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");
+ 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: function(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: function(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: function() {
+ this.listbox.innerHTML = "";
+ },
+
+ toggleList: function(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: function(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: function(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: function(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: function(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: function(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: function(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: function(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/toolkit/components/narrate/moz.build b/toolkit/components/narrate/moz.build
new file mode 100644
index 000000000..c5597c369
--- /dev/null
+++ b/toolkit/components/narrate/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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'
+]
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
diff --git a/toolkit/components/narrate/test/.eslintrc.js b/toolkit/components/narrate/test/.eslintrc.js
new file mode 100644
index 000000000..5ff0bae7e
--- /dev/null
+++ b/toolkit/components/narrate/test/.eslintrc.js
@@ -0,0 +1,23 @@
+"use strict";
+
+module.exports = { // eslint-disable-line no-undef
+ "extends": [
+ "../.eslintrc.js"
+ ],
+
+ "globals": {
+ "is": true,
+ "isnot": true,
+ "ok": true,
+ "NarrateTestUtils": true,
+ "content": true,
+ "ContentTaskUtils": true,
+ "ContentTask": true,
+ "BrowserTestUtils": true,
+ "gBrowser": true,
+ },
+
+ "rules": {
+ "mozilla/import-headjs-globals": "warn"
+ }
+};
diff --git a/toolkit/components/narrate/test/NarrateTestUtils.jsm b/toolkit/components/narrate/test/NarrateTestUtils.jsm
new file mode 100644
index 000000000..b782f66c9
--- /dev/null
+++ b/toolkit/components/narrate/test/NarrateTestUtils.jsm
@@ -0,0 +1,148 @@
+/* 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/Services.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://testing-common/ContentTaskUtils.jsm");
+
+this.EXPORTED_SYMBOLS = [ "NarrateTestUtils" ];
+
+this.NarrateTestUtils = {
+ TOGGLE: "#narrate-toggle",
+ POPUP: "#narrate-dropdown .dropdown-popup",
+ VOICE_SELECT: "#narrate-voices .select-toggle",
+ VOICE_OPTIONS: "#narrate-voices .options",
+ VOICE_SELECTED: "#narrate-voices .options .option.selected",
+ VOICE_SELECT_LABEL: "#narrate-voices .select-toggle .current-voice",
+ RATE: "#narrate-rate-input",
+ START: "#narrate-dropdown:not(.speaking) #narrate-start-stop",
+ STOP: "#narrate-dropdown.speaking #narrate-start-stop",
+ BACK: "#narrate-skip-previous",
+ FORWARD: "#narrate-skip-next",
+
+ isVisible: function(element) {
+ let style = element.ownerDocument.defaultView.getComputedStyle(element, "");
+ if (style.display == "none") {
+ return false;
+ } else if (style.visibility != "visible") {
+ return false;
+ } else if (style.display == "-moz-popup" && element.state != "open") {
+ return false;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument) {
+ return this.isVisible(element.parentNode);
+ }
+
+ return true;
+ },
+
+ isStoppedState: function(window, ok) {
+ let $ = window.document.querySelector.bind(window.document);
+ ok($(this.BACK).disabled, "back button is disabled");
+ ok($(this.FORWARD).disabled, "forward button is disabled");
+ ok(!!$(this.START), "start button is showing");
+ ok(!$(this.STOP), "stop button is hidden");
+ // This checks for a localized label. Not the best...
+ ok($(this.START).title == "Start", "Button tooltip is correct");
+ },
+
+ isStartedState: function(window, ok) {
+ let $ = window.document.querySelector.bind(window.document);
+ ok(!$(this.BACK).disabled, "back button is enabled");
+ ok(!$(this.FORWARD).disabled, "forward button is enabled");
+ ok(!$(this.START), "start button is hidden");
+ ok(!!$(this.STOP), "stop button is showing");
+ // This checks for a localized label. Not the best...
+ ok($(this.STOP).title == "Stop", "Button tooltip is correct");
+ },
+
+ selectVoice: function(window, voiceUri) {
+ if (!this.isVisible(window.document.querySelector(this.VOICE_OPTIONS))) {
+ window.document.querySelector(this.VOICE_SELECT).click();
+ }
+
+ let voiceOption = window.document.querySelector(
+ `#narrate-voices .option[data-value="${voiceUri}"]`);
+
+ voiceOption.focus();
+ voiceOption.click();
+
+ return voiceOption.classList.contains("selected");
+ },
+
+ getEventUtils: function(window) {
+ let eventUtils = {
+ "_EU_Ci": Components.interfaces,
+ "_EU_Cc": Components.classes,
+ window: window,
+ parent: window,
+ navigator: window.navigator,
+ KeyboardEvent: window.KeyboardEvent,
+ KeyEvent: window.KeyEvent
+ };
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", eventUtils);
+ return eventUtils;
+ },
+
+ getReaderReadyPromise: function(window) {
+ return new Promise(resolve => {
+ function observeReady(subject, topic) {
+ if (subject == window) {
+ Services.obs.removeObserver(observeReady, topic);
+ resolve();
+ }
+ }
+
+ if (window.document.body.classList.contains("loaded")) {
+ resolve();
+ } else {
+ Services.obs.addObserver(observeReady, "AboutReader:Ready", false);
+ }
+ });
+ },
+
+ waitForNarrateToggle: function(window) {
+ let toggle = window.document.querySelector(this.TOGGLE);
+ return ContentTaskUtils.waitForCondition(
+ () => !toggle.hidden, "");
+ },
+
+ waitForPrefChange: function(pref) {
+ return new Promise(resolve => {
+ function observeChange() {
+ Services.prefs.removeObserver(pref, observeChange);
+ resolve(Preferences.get(pref));
+ }
+
+ Services.prefs.addObserver(pref, observeChange, false);
+ });
+ },
+
+ sendBoundaryEvent: function(window, name, charIndex) {
+ let detail = { type: "boundary", args: { name, charIndex } };
+ window.dispatchEvent(new window.CustomEvent("testsynthevent",
+ { detail: detail }));
+ },
+
+ isWordHighlightGone: function(window, ok) {
+ let $ = window.document.querySelector.bind(window.document);
+ ok(!$(".narrate-word-highlight"), "No more word highlights exist");
+ },
+
+ getWordHighlights: function(window) {
+ let $$ = window.document.querySelectorAll.bind(window.document);
+ let nodes = Array.from($$(".narrate-word-highlight"));
+ return nodes.map(node => {
+ return { word: node.dataset.word,
+ left: Number(node.style.left.replace(/px$/, "")),
+ top: Number(node.style.top.replace(/px$/, ""))};
+ });
+ }
+};
diff --git a/toolkit/components/narrate/test/browser.ini b/toolkit/components/narrate/test/browser.ini
new file mode 100644
index 000000000..0f5d694ac
--- /dev/null
+++ b/toolkit/components/narrate/test/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+support-files =
+ head.js
+ NarrateTestUtils.jsm
+ moby_dick.html
+
+[browser_narrate.js]
+[browser_narrate_disable.js]
+[browser_narrate_language.js]
+support-files = inferno.html
+[browser_voiceselect.js]
+[browser_word_highlight.js]
diff --git a/toolkit/components/narrate/test/browser_narrate.js b/toolkit/components/narrate/test/browser_narrate.js
new file mode 100644
index 000000000..b4951ef9f
--- /dev/null
+++ b/toolkit/components/narrate/test/browser_narrate.js
@@ -0,0 +1,137 @@
+/* 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/. */
+
+/* globals is, isnot, registerCleanupFunction, add_task */
+
+"use strict";
+
+registerCleanupFunction(teardown);
+
+add_task(function* testNarrate() {
+ setup();
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
+ let TEST_VOICE = "urn:moz-tts:fake-indirect:teresa";
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ let popup = $(NarrateTestUtils.POPUP);
+ ok(!NarrateTestUtils.isVisible(popup), "popup is initially hidden");
+
+ let toggle = $(NarrateTestUtils.TOGGLE);
+ toggle.click();
+
+ ok(NarrateTestUtils.isVisible(popup), "popup toggled");
+
+ let voiceOptions = $(NarrateTestUtils.VOICE_OPTIONS);
+ ok(!NarrateTestUtils.isVisible(voiceOptions),
+ "voice options are initially hidden");
+
+ $(NarrateTestUtils.VOICE_SELECT).click();
+ ok(NarrateTestUtils.isVisible(voiceOptions), "voice options pop up");
+
+ let prefChanged = NarrateTestUtils.waitForPrefChange("narrate.voice");
+ ok(NarrateTestUtils.selectVoice(content, TEST_VOICE),
+ "test voice selected");
+ yield prefChanged;
+
+ ok(!NarrateTestUtils.isVisible(voiceOptions), "voice options hidden again");
+
+ NarrateTestUtils.isStoppedState(content, ok);
+
+ let promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.START).click();
+ let speechinfo = (yield promiseEvent).detail;
+ is(speechinfo.voice, TEST_VOICE, "correct voice is being used");
+ let paragraph = speechinfo.paragraph;
+
+ NarrateTestUtils.isStartedState(content, ok);
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.FORWARD).click();
+ speechinfo = (yield promiseEvent).detail;
+ is(speechinfo.voice, TEST_VOICE, "same voice is used");
+ isnot(speechinfo.paragraph, paragraph, "next paragraph is being spoken");
+
+ NarrateTestUtils.isStartedState(content, ok);
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.BACK).click();
+ speechinfo = (yield promiseEvent).detail;
+ is(speechinfo.paragraph, paragraph, "first paragraph being spoken");
+
+ NarrateTestUtils.isStartedState(content, ok);
+
+ paragraph = speechinfo.paragraph;
+ $(NarrateTestUtils.STOP).click();
+ yield ContentTaskUtils.waitForCondition(
+ () => !$(NarrateTestUtils.STOP), "transitioned to stopped state");
+ NarrateTestUtils.isStoppedState(content, ok);
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.START).click();
+ speechinfo = (yield promiseEvent).detail;
+ is(speechinfo.paragraph, paragraph, "read same paragraph again");
+
+ NarrateTestUtils.isStartedState(content, ok);
+
+ let eventUtils = NarrateTestUtils.getEventUtils(content);
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ prefChanged = NarrateTestUtils.waitForPrefChange("narrate.rate");
+ $(NarrateTestUtils.RATE).focus();
+ eventUtils.sendKey("UP", content);
+ let newspeechinfo = (yield promiseEvent).detail;
+ is(newspeechinfo.paragraph, speechinfo.paragraph, "same paragraph");
+ isnot(newspeechinfo.rate, speechinfo.rate, "rate changed");
+ yield prefChanged;
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphend");
+ $(NarrateTestUtils.STOP).click();
+ yield promiseEvent;
+
+ yield ContentTaskUtils.waitForCondition(
+ () => !$(NarrateTestUtils.STOP), "transitioned to stopped state");
+ NarrateTestUtils.isStoppedState(content, ok);
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "scroll");
+ content.scrollBy(0, 10);
+ yield promiseEvent;
+ ok(!NarrateTestUtils.isVisible(popup), "popup is hidden after scroll");
+
+ toggle.click();
+ ok(NarrateTestUtils.isVisible(popup), "popup is toggled again");
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.START).click();
+ yield promiseEvent;
+ NarrateTestUtils.isStartedState(content, ok);
+
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "scroll");
+ content.scrollBy(0, -10);
+ yield promiseEvent;
+ ok(NarrateTestUtils.isVisible(popup), "popup stays visible after scroll");
+
+ toggle.click();
+ ok(!NarrateTestUtils.isVisible(popup), "popup is dismissed while speaking");
+ NarrateTestUtils.isStartedState(content, ok);
+
+ // Go forward all the way to the end of the article. We should eventually
+ // stop.
+ do {
+ promiseEvent = Promise.race([
+ ContentTaskUtils.waitForEvent(content, "paragraphstart"),
+ ContentTaskUtils.waitForEvent(content, "paragraphsdone")]);
+ $(NarrateTestUtils.FORWARD).click();
+ } while ((yield promiseEvent).type == "paragraphstart");
+
+ // This is to make sure we are not actively scrolling when the tab closes.
+ content.scroll(0, 0);
+
+ yield ContentTaskUtils.waitForCondition(
+ () => !$(NarrateTestUtils.STOP), "transitioned to stopped state");
+ NarrateTestUtils.isStoppedState(content, ok);
+ });
+});
diff --git a/toolkit/components/narrate/test/browser_narrate_disable.js b/toolkit/components/narrate/test/browser_narrate_disable.js
new file mode 100644
index 000000000..264815fd1
--- /dev/null
+++ b/toolkit/components/narrate/test/browser_narrate_disable.js
@@ -0,0 +1,37 @@
+/* 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/. */
+
+/* globals registerCleanupFunction, add_task */
+
+"use strict";
+
+const ENABLE_PREF = "narrate.enabled";
+
+registerCleanupFunction(() => {
+ clearUserPref(ENABLE_PREF);
+ teardown();
+});
+
+add_task(function* testNarratePref() {
+ setup();
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function() {
+ is(content.document.querySelectorAll(NarrateTestUtils.TOGGLE).length, 1,
+ "narrate is inserted by default");
+ });
+
+ setBoolPref(ENABLE_PREF, false);
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function() {
+ ok(!content.document.querySelector(NarrateTestUtils.TOGGLE),
+ "narrate is disabled and is not in reader mode");
+ });
+
+ setBoolPref(ENABLE_PREF, true);
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function() {
+ is(content.document.querySelectorAll(NarrateTestUtils.TOGGLE).length, 1,
+ "narrate is re-enabled and appears only once");
+ });
+});
diff --git a/toolkit/components/narrate/test/browser_narrate_language.js b/toolkit/components/narrate/test/browser_narrate_language.js
new file mode 100644
index 000000000..2542a87d6
--- /dev/null
+++ b/toolkit/components/narrate/test/browser_narrate_language.js
@@ -0,0 +1,73 @@
+/* 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/. */
+
+/* globals is, isnot, registerCleanupFunction, add_task */
+
+"use strict";
+
+registerCleanupFunction(teardown);
+
+add_task(function* testVoiceselectDropdownAutoclose() {
+ setup("automatic", true);
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ ok(!!$(".option[data-value='urn:moz-tts:fake-direct:bob']"),
+ "Jamaican English voice available");
+ ok(!!$(".option[data-value='urn:moz-tts:fake-direct:lenny']"),
+ "Canadian English voice available");
+ ok(!!$(".option[data-value='urn:moz-tts:fake-direct:amy']"),
+ "British English voice available");
+
+ ok(!$(".option[data-value='urn:moz-tts:fake-direct:celine']"),
+ "Canadian French voice unavailable");
+ ok(!$(".option[data-value='urn:moz-tts:fake-direct:julie']"),
+ "Mexican Spanish voice unavailable");
+
+ $(NarrateTestUtils.TOGGLE).click();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "popup is toggled");
+
+ let prefChanged = NarrateTestUtils.waitForPrefChange(
+ "narrate.voice", "getCharPref");
+ NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-direct:lenny");
+ let voicePref = JSON.parse(yield prefChanged);
+ is(voicePref.en, "urn:moz-tts:fake-direct:lenny", "pref set correctly");
+ });
+});
+
+add_task(function* testVoiceselectDropdownAutoclose() {
+ setup("automatic", true);
+
+ yield spawnInNewReaderTab(TEST_ITALIAN_ARTICLE, function* () {
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ ok(!!$(".option[data-value='urn:moz-tts:fake-indirect:zanetta']"),
+ "Italian voice available");
+ ok(!!$(".option[data-value='urn:moz-tts:fake-indirect:margherita']"),
+ "Italian voice available");
+
+ ok(!$(".option[data-value='urn:moz-tts:fake-direct:bob']"),
+ "Jamaican English voice available");
+ ok(!$(".option[data-value='urn:moz-tts:fake-direct:celine']"),
+ "Canadian French voice unavailable");
+ ok(!$(".option[data-value='urn:moz-tts:fake-direct:julie']"),
+ "Mexican Spanish voice unavailable");
+
+ $(NarrateTestUtils.TOGGLE).click();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "popup is toggled");
+
+ let prefChanged = NarrateTestUtils.waitForPrefChange(
+ "narrate.voice", "getCharPref");
+ NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-indirect:zanetta");
+ let voicePref = JSON.parse(yield prefChanged);
+ is(voicePref.it, "urn:moz-tts:fake-indirect:zanetta", "pref set correctly");
+ });
+});
diff --git a/toolkit/components/narrate/test/browser_voiceselect.js b/toolkit/components/narrate/test/browser_voiceselect.js
new file mode 100644
index 000000000..0de6528dd
--- /dev/null
+++ b/toolkit/components/narrate/test/browser_voiceselect.js
@@ -0,0 +1,112 @@
+/* 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/. */
+
+/* globals registerCleanupFunction, add_task, is, isnot */
+
+"use strict";
+
+registerCleanupFunction(teardown);
+
+add_task(function* testVoiceselectDropdownAutoclose() {
+ setup();
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ $(NarrateTestUtils.TOGGLE).click();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "popup is toggled");
+
+ ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
+ "voice options are initially hidden");
+
+ $(NarrateTestUtils.VOICE_SELECT).click();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
+ "voice options are toggled");
+
+ $(NarrateTestUtils.TOGGLE).click();
+ // A focus will follow a real click.
+ $(NarrateTestUtils.TOGGLE).focus();
+ ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "narrate popup is dismissed");
+
+ $(NarrateTestUtils.TOGGLE).click();
+ // A focus will follow a real click.
+ $(NarrateTestUtils.TOGGLE).focus();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "narrate popup is showing again");
+ ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
+ "voice options are hidden after popup comes back");
+ });
+});
+
+add_task(function* testVoiceselectLabelChange() {
+ setup();
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ $(NarrateTestUtils.TOGGLE).click();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "popup is toggled");
+
+ ok(NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-direct:lenny"),
+ "voice selected");
+
+ let selectedOption = $(NarrateTestUtils.VOICE_SELECTED);
+ let selectLabel = $(NarrateTestUtils.VOICE_SELECT_LABEL);
+
+ is(selectedOption.textContent, selectLabel.textContent,
+ "new label matches selected voice");
+ });
+});
+
+add_task(function* testVoiceselectKeyboard() {
+ setup();
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ $(NarrateTestUtils.TOGGLE).click();
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)),
+ "popup is toggled");
+
+ let eventUtils = NarrateTestUtils.getEventUtils(content);
+
+ let firstValue = $(NarrateTestUtils.VOICE_SELECTED).dataset.value;
+
+ ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
+ "voice options initially are hidden");
+
+ $(NarrateTestUtils.VOICE_SELECT).focus();
+
+ eventUtils.sendKey("DOWN", content);
+
+ yield ContentTaskUtils.waitForCondition(
+ () => $(NarrateTestUtils.VOICE_SELECTED).dataset.value != firstValue,
+ "value changed after pressing DOWN key");
+
+ eventUtils.sendKey("RETURN", content);
+
+ ok(NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
+ "voice options showing after pressing RETURN");
+
+ eventUtils.sendKey("UP", content);
+
+ eventUtils.sendKey("RETURN", content);
+
+ ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)),
+ "voice options hidden after pressing RETURN");
+
+ yield ContentTaskUtils.waitForCondition(
+ () => $(NarrateTestUtils.VOICE_SELECTED).dataset.value == firstValue,
+ "value changed back to original after pressing RETURN");
+ });
+});
diff --git a/toolkit/components/narrate/test/browser_word_highlight.js b/toolkit/components/narrate/test/browser_word_highlight.js
new file mode 100644
index 000000000..bfdbcf48e
--- /dev/null
+++ b/toolkit/components/narrate/test/browser_word_highlight.js
@@ -0,0 +1,69 @@
+/* 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/. */
+
+/* globals is, isnot, registerCleanupFunction, add_task */
+
+"use strict";
+
+registerCleanupFunction(teardown);
+
+add_task(function* testNarrate() {
+ setup("urn:moz-tts:fake-indirect:teresa");
+
+ yield spawnInNewReaderTab(TEST_ARTICLE, function* () {
+ let $ = content.document.querySelector.bind(content.document);
+
+ yield NarrateTestUtils.waitForNarrateToggle(content);
+
+ let popup = $(NarrateTestUtils.POPUP);
+ ok(!NarrateTestUtils.isVisible(popup), "popup is initially hidden");
+
+ let toggle = $(NarrateTestUtils.TOGGLE);
+ toggle.click();
+
+ ok(NarrateTestUtils.isVisible(popup), "popup toggled");
+
+ NarrateTestUtils.isStoppedState(content, ok);
+
+ let promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.START).click();
+ let voice = (yield promiseEvent).detail.voice;
+ is(voice, "urn:moz-tts:fake-indirect:teresa", "double-check voice");
+
+ // Skip forward to first paragraph.
+ let details;
+ do {
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart");
+ $(NarrateTestUtils.FORWARD).click();
+ details = (yield promiseEvent).detail;
+ } while (details.tag != "p");
+
+ let boundaryPat = /(\s+)\S/g;
+ let position = { left: 0, top: 0 };
+ let text = details.paragraph;
+ for (let res = boundaryPat.exec(text); res; res = boundaryPat.exec(text)) {
+ promiseEvent = ContentTaskUtils.waitForEvent(content, "wordhighlight");
+ NarrateTestUtils.sendBoundaryEvent(content, "word", res.index);
+ let { start, end } = (yield promiseEvent).detail;
+ let nodes = NarrateTestUtils.getWordHighlights(content);
+ for (let node of nodes) {
+ // Since this is English we can assume each word is to the right or
+ // below the previous one.
+ ok(node.left > position.left || node.top > position.top,
+ "highlight position is moving");
+ position = { left: node.left, top: node.top };
+ }
+ let wordFromOffset = text.substring(start, end);
+ // XXX: Each node should contain the part of the word it highlights.
+ // Right now, each node contains the entire word.
+ let wordFromHighlight = nodes[0].word;
+ is(wordFromOffset, wordFromHighlight, "Correct word is highlighted");
+ }
+
+ $(NarrateTestUtils.STOP).click();
+ yield ContentTaskUtils.waitForCondition(
+ () => !$(NarrateTestUtils.STOP), "transitioned to stopped state");
+ NarrateTestUtils.isWordHighlightGone(content, ok);
+ });
+});
diff --git a/toolkit/components/narrate/test/head.js b/toolkit/components/narrate/test/head.js
new file mode 100644
index 000000000..491a3da8d
--- /dev/null
+++ b/toolkit/components/narrate/test/head.js
@@ -0,0 +1,87 @@
+/* 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/. */
+
+/* exported teardown, setup, toggleExtension,
+ spawnInNewReaderTab, TEST_ARTICLE, TEST_ITALIAN_ARTICLE */
+
+"use strict";
+
+const TEST_ARTICLE =
+ "http://example.com/browser/toolkit/components/narrate/test/moby_dick.html";
+
+const TEST_ITALIAN_ARTICLE =
+ "http://example.com/browser/toolkit/components/narrate/test/inferno.html";
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+ "resource://gre/modules/AddonManager.jsm");
+
+const TEST_PREFS = {
+ "reader.parse-on-load.enabled": true,
+ "media.webspeech.synth.enabled": true,
+ "media.webspeech.synth.test": true,
+ "narrate.enabled": true,
+ "narrate.test": true,
+ "narrate.voice": null,
+ "narrate.filter-voices": false,
+};
+
+function setup(voiceUri = "automatic", filterVoices = false) {
+ let prefs = Object.assign({}, TEST_PREFS, {
+ "narrate.filter-voices": filterVoices,
+ "narrate.voice": JSON.stringify({ en: voiceUri })
+ });
+
+ // Set required test prefs.
+ Object.entries(prefs).forEach(([name, value]) => {
+ switch (typeof value) {
+ case "boolean":
+ setBoolPref(name, value);
+ break;
+ case "string":
+ setCharPref(name, value);
+ break;
+ }
+ });
+}
+
+function teardown() {
+ // Reset test prefs.
+ Object.entries(TEST_PREFS).forEach(pref => {
+ clearUserPref(pref[0]);
+ });
+}
+
+function spawnInNewReaderTab(url, func) {
+ return BrowserTestUtils.withNewTab(
+ { gBrowser,
+ url: `about:reader?url=${encodeURIComponent(url)}` },
+ function* (browser) {
+ yield ContentTask.spawn(browser, null, function* () {
+ Components.utils.import("chrome://mochitests/content/browser/" +
+ "toolkit/components/narrate/test/NarrateTestUtils.jsm");
+
+ yield NarrateTestUtils.getReaderReadyPromise(content);
+ });
+
+ yield ContentTask.spawn(browser, null, func);
+ });
+}
+
+function setBoolPref(name, value) {
+ Services.prefs.setBoolPref(name, value);
+}
+
+function setCharPref(name, value) {
+ Services.prefs.setCharPref(name, value);
+}
+
+function clearUserPref(name) {
+ Services.prefs.clearUserPref(name);
+}
diff --git a/toolkit/components/narrate/test/inferno.html b/toolkit/components/narrate/test/inferno.html
new file mode 100644
index 000000000..58dfd24df
--- /dev/null
+++ b/toolkit/components/narrate/test/inferno.html
@@ -0,0 +1,238 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Inferno - Canto I</title>
+</head>
+<body>
+ <h1>Inferno</h1>
+ <h2>Canto I: Dante nella selva oscura</h2>
+ <p>
+ Nel mezzo del cammin di nostra vita<br>
+ mi ritrovai per una selva oscura,<br>
+ ché la diritta via era smarrita.
+ </p>
+ <p>
+ Ahi quanto a dir qual era è cosa dura<br>
+ esta selva selvaggia e aspra e forte<br>
+ che nel pensier rinova la paura!
+ </p>
+ <p>
+ Tant' è amara che poco è più morte;<br>
+ ma per trattar del ben ch'i' vi trovai,<br>
+ dirò de l'altre cose ch'i' v'ho scorte.
+ </p>
+ <p>
+ Io non so ben ridir com' i' v'intrai,<br>
+ tant' era pien di sonno a quel punto<br>
+ che la verace via abbandonai.
+ </p>
+ <p>
+ Ma poi ch'i' fui al piè d'un colle giunto,<br>
+ là dove terminava quella valle<br>
+ che m'avea di paura il cor compunto,
+ </p>
+ <p>
+ guardai in alto e vidi le sue spalle<br>
+ vestite già de' raggi del pianeta<br>
+ che mena dritto altrui per ogne calle.
+ </p>
+ <p>
+ Allor fu la paura un poco queta,<br>
+ che nel lago del cor m'era durata<br>
+ la notte ch'i' passai con tanta pieta.
+ </p>
+ <p>
+ E come quei che con lena affannata,<br>
+ uscito fuor del pelago a la riva,<br>
+ si volge a l'acqua perigliosa e guata,
+ </p>
+ <p>
+ così l'animo mio, ch'ancor fuggiva,<br>
+ si volse a retro a rimirar lo passo<br>
+ che non lasciò già mai persona viva.
+ </p>
+ <p>
+ Poi ch'èi posato un poco il corpo lasso,<br>
+ ripresi via per la piaggia diserta,<br>
+ sì che 'l piè fermo sempre era 'l più basso.
+ </p>
+ <p>
+ Ed ecco, quasi al cominciar de l'erta,<br>
+ una lonza leggiera e presta molto,<br>
+ che di pel macolato era coverta;
+ </p>
+ <p>
+ e non mi si partia dinanzi al volto,<br>
+ anzi 'mpediva tanto il mio cammino,<br>
+ ch'i' fui per ritornar più volte vòlto.
+ </p>
+ <p>
+ Temp' era dal principio del mattino,<br>
+ e 'l sol montava 'n sù con quelle stelle<br>
+ ch'eran con lui quando l'amor divino
+ </p>
+ <p>
+ mosse di prima quelle cose belle;<br>
+ sì ch'a bene sperar m'era cagione<br>
+ di quella fiera a la gaetta pelle
+ </p>
+ <p>
+ l'ora del tempo e la dolce stagione;<br>
+ ma non sì che paura non mi desse<br>
+ la vista che m'apparve d'un leone.
+ </p>
+ <p>
+ Questi parea che contra me venisse<br>
+ con la test' alta e con rabbiosa fame,<br>
+ sì che parea che l'aere ne tremesse.
+ </p>
+ <p>
+ Ed una lupa, che di tutte brame<br>
+ sembiava carca ne la sua magrezza,<br>
+ e molte genti fé già viver grame,
+ </p>
+ <p>
+ questa mi porse tanto di gravezza<br>
+ con la paura ch'uscia di sua vista,<br>
+ ch'io perdei la speranza de l'altezza.
+ </p>
+ <p>
+ E qual è quei che volontieri acquista,<br>
+ e giugne 'l tempo che perder lo face,<br>
+ che 'n tutti suoi pensier piange e s'attrista;
+ </p>
+ <p>
+ tal mi fece la bestia sanza pace,<br>
+ che, venendomi 'ncontro, a poco a poco<br>
+ mi ripigneva là dove 'l sol tace.
+ </p>
+ <p>
+ Mentre ch'i' rovinava in basso loco,<br>
+ dinanzi a li occhi mi si fu offerto<br>
+ chi per lungo silenzio parea fioco.
+ </p>
+ <p>
+ Quando vidi costui nel gran diserto,<br>
+ «<em>Miserere</em> di me», gridai a lui,<br>
+ «qual che tu sii, od ombra od omo certo!».
+ </p>
+ <p>
+ Rispuosemi: «Non omo, omo già fui,<br>
+ e li parenti miei furon lombardi,<br>
+ mantoani per patrïa ambedui.
+ </p>
+ <p>
+ Nacqui <em>sub Iulio</em>, ancor che fosse tardi,<br>
+ e vissi a Roma sotto 'l buono Augusto<br>
+ nel tempo de li dèi falsi e bugiardi.
+ </p>
+ <p>
+ Poeta fui, e cantai di quel giusto<br>
+ figliuol d'Anchise che venne di Troia,<br>
+ poi che 'l superbo Ilïón fu combusto.
+ </p>
+ <p>
+ Ma tu perché ritorni a tanta noia?<br>
+ perché non sali il dilettoso monte<br>
+ ch'è principio e cagion di tutta gioia?».
+ </p>
+ <p>
+ «Or se' tu quel Virgilio e quella fonte<br>
+ che spandi di parlar sì largo fiume?»,<br>
+ rispuos' io lui con vergognosa fronte.
+ </p>
+ <p>
+ «O de li altri poeti onore e lume,<br>
+ vagliami 'l lungo studio e 'l grande amore<br>
+ che m'ha fatto cercar lo tuo volume.
+ </p>
+ <p>
+ Tu se' lo mio maestro e 'l mio autore,<br>
+ tu se' solo colui da cu' io tolsi<br>
+ lo bello stilo che m'ha fatto onore.
+ </p>
+ <p>
+ Vedi la bestia per cu' io mi volsi;<br>
+ aiutami da lei, famoso saggio,<br>
+ ch'ella mi fa tremar le vene e i polsi».
+ </p>
+ <p>
+ «A te convien tenere altro vïaggio»,<br>
+ rispuose, poi che lagrimar mi vide,<br>
+ «se vuo' campar d'esto loco selvaggio;
+ </p>
+ <p>
+ ché questa bestia, per la qual tu gride,<br>
+ non lascia altrui passar per la sua via,<br>
+ ma tanto lo 'mpedisce che l'uccide;
+ </p>
+ <p>
+ e ha natura sì malvagia e ria,<br>
+ che mai non empie la bramosa voglia,<br>
+ e dopo 'l pasto ha più fame che pria.
+ </p>
+ <p>
+ Molti son li animali a cui s'ammoglia,<br>
+ e più saranno ancora, infin che 'l veltro<br>
+ verrà, che la farà morir con doglia.
+ </p>
+ <p>
+ Questi non ciberà terra né peltro,<br>
+ ma sapïenza, amore e virtute,<br>
+ e sua nazion sarà tra feltro e feltro.
+ </p>
+ <p>
+ Di quella umile Italia fia salute<br>
+ per cui morì la vergine Cammilla,<br>
+ Eurialo e Turno e Niso di ferute.
+ </p>
+ <p>
+ Questi la caccerà per ogne villa,<br>
+ fin che l'avrà rimessa ne lo 'nferno,<br>
+ là onde 'nvidia prima dipartilla.
+ </p>
+ <p>
+ Ond' io per lo tuo me' penso e discerno<br>
+ che tu mi segui, e io sarò tua guida,<br>
+ e trarrotti di qui per loco etterno;
+ </p>
+ <p>
+ ove udirai le disperate strida,<br>
+ vedrai li antichi spiriti dolenti,<br>
+ ch'a la seconda morte ciascun grida;
+ </p>
+ <p>
+ e vederai color che son contenti<br>
+ nel foco, perché speran di venire<br>
+ quando che sia a le beate genti.
+ </p>
+ <p>
+ A le quai poi se tu vorrai salire,<br>
+ anima fia a ciò più di me degna:<br>
+ con lei ti lascerò nel mio partire;
+ </p>
+ <p>
+ ché quello imperador che là sù regna,<br>
+ perch' i' fu' ribellante a la sua legge,<br>
+ non vuol che 'n sua città per me si vegna.
+ </p>
+ <p>
+ In tutte parti impera e quivi regge;<br>
+ quivi è la sua città e l'alto seggio:<br>
+ oh felice colui cu' ivi elegge!».
+ </p>
+ <p>
+ E io a lui: «Poeta, io ti richeggio<br>
+ per quello Dio che tu non conoscesti,<br>
+ a ciò ch'io fugga questo male e peggio,
+ </p>
+ <p>
+ che tu mi meni là dov' or dicesti,<br>
+ sì ch'io veggia la porta di san Pietro<br>
+ e color cui tu fai cotanto mesti».
+ </p>
+ <p>
+ Allor si mosse, e io li tenni dietro.
+ </p>
+</body>
+</html>
diff --git a/toolkit/components/narrate/test/moby_dick.html b/toolkit/components/narrate/test/moby_dick.html
new file mode 100644
index 000000000..0beaa20fd
--- /dev/null
+++ b/toolkit/components/narrate/test/moby_dick.html
@@ -0,0 +1,218 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Moby Dick - Chapter 1. Loomings</title>
+</head>
+<body>
+ <h1>Moby Dick</h1>
+ <h2>Chapter 1. Loomings</h2>
+ <p>
+ Call me Ishmael. <span>Some <span>years</span></span> ago—never mind how
+ long precisely—having little or no money in my purse, and nothing particular
+ to interest me on shore, I thought I would sail about a little and see the
+ watery part of the world. It is a way I have of driving off the spleen and
+ regulating the circulation. Whenever I find myself growing grim about the
+ mouth; whenever it is a damp, drizzly November in my soul; whenever I find
+ myself involuntarily pausing before coffin warehouses, and bringing up the
+ rear of every funeral I meet; and especially whenever my hypos get such an
+ upper hand of me, that it requires a strong moral principle to prevent me
+ from deliberately stepping into the street, and methodically knocking
+ people's hats off—then, I account it high time to get to sea as soon as I
+ can. This is my substitute for pistol and ball. With a philosophical
+ flourish Cato throws himself upon his sword; I quietly take to the ship.
+ There is nothing surprising in this. If they but knew it, almost all men in
+ their degree, some time or other, cherish very nearly the same feelings
+ towards the ocean with me.
+ </p>
+ <p>
+ There now is your insular city of the Manhattoes, belted round by wharves
+ as Indian isles by coral reefs—commerce surrounds it with her surf.
+ Right and left, the streets take you waterward. Its extreme downtown is
+ the battery, where that noble mole is washed by waves, and cooled by
+ breezes, which a few hours previous were out of sight of land. Look at the
+ crowds of water-gazers there.
+ </p>
+ <p>
+ Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears
+ Hook to Coenties Slip, and from thence, by Whitehall, northward. What do
+ you see?—Posted like silent sentinels all around the town, stand
+ thousands upon thousands of mortal men fixed in ocean reveries. Some
+ leaning against the spiles; some seated upon the pier-heads; some looking
+ over the bulwarks of ships from China; some high aloft in the rigging, as
+ if striving to get a still better seaward peep. But these are all
+ landsmen; of week days pent up in lath and plaster—tied to counters,
+ nailed to benches, clinched to desks. How then is this? Are the green
+ fields gone? What do they here?
+ </p>
+ <p>
+ But look! here come more crowds, pacing straight for the water, and
+ seemingly bound for a dive. Strange! Nothing will content them but the
+ extremest limit of the land; loitering under the shady lee of yonder
+ warehouses will not suffice. No. They must get just as nigh the water as
+ they possibly can without falling in. And there they stand—miles of
+ them—leagues. Inlanders all, they come from lanes and alleys,
+ streets and avenues—north, east, south, and west. Yet here they all
+ unite. Tell me, does the magnetic virtue of the needles of the compasses
+ of all those ships attract them thither?
+ </p>
+ <p>
+ Once more. Say you are in the country; in some high land of lakes. Take
+ almost any path you please, and ten to one it carries you down in a dale,
+ and leaves you there by a pool in the stream. There is magic in it. Let
+ the most absent-minded of men be plunged in his deepest reveries—stand
+ that man on his legs, set his feet a-going, and he will infallibly lead
+ you to water, if water there be in all that region. Should you ever be
+ athirst in the great American desert, try this experiment, if your caravan
+ happen to be supplied with a metaphysical professor. Yes, as every one
+ knows, meditation and water are wedded for ever.
+ </p>
+ <p>
+ But here is an artist. He desires to paint you the dreamiest, shadiest,
+ quietest, most enchanting bit of romantic landscape in all the valley of
+ the Saco. What is the chief element he employs? There stand his trees,
+ each with a hollow trunk, as if a hermit and a crucifix were within; and
+ here sleeps his meadow, and there sleep his cattle; and up from yonder
+ cottage goes a sleepy smoke. Deep into distant woodlands winds a mazy way,
+ reaching to overlapping spurs of mountains bathed in their hill-side blue.
+ But though the picture lies thus tranced, and though this pine-tree shakes
+ down its sighs like leaves upon this shepherd's head, yet all were vain,
+ unless the shepherd's eye were fixed upon the magic stream before him. Go
+ visit the Prairies in June, when for scores on scores of miles you wade
+ knee-deep among Tiger-lilies—what is the one charm wanting?—Water—there
+ is not a drop of water there! Were Niagara but a cataract of sand, would
+ you travel your thousand miles to see it? Why did the poor poet of
+ Tennessee, upon suddenly receiving two handfuls of silver, deliberate
+ whether to buy him a coat, which he sadly needed, or invest his money in a
+ pedestrian trip to Rockaway Beach? Why is almost every robust healthy boy
+ with a robust healthy soul in him, at some time or other crazy to go to
+ sea? Why upon your first voyage as a passenger, did you yourself feel such
+ a mystical vibration, when first told that you and your ship were now out
+ of sight of land? Why did the old Persians hold the sea holy? Why did the
+ Greeks give it a separate deity, and own brother of Jove? Surely all this
+ is not without meaning. And still deeper the meaning of that story of
+ Narcissus, who because he could not grasp the tormenting, mild image he
+ saw in the fountain, plunged into it and was drowned. But that same image,
+ we ourselves see in all rivers and oceans. It is the image of the
+ ungraspable phantom of life; and this is the key to it all.
+ </p>
+ <p>
+ Now, when I say that I am in the habit of going to sea whenever I begin to
+ grow hazy about the eyes, and begin to be over conscious of my lungs, I do
+ not mean to have it inferred that I ever go to sea as a passenger. For to
+ go as a passenger you must needs have a purse, and a purse is but a rag
+ unless you have something in it. Besides, passengers get sea-sick—grow
+ quarrelsome—don't sleep of nights—do not enjoy themselves
+ much, as a general thing;—no, I never go as a passenger; nor, though
+ I am something of a salt, do I ever go to sea as a Commodore, or a
+ Captain, or a Cook. I abandon the glory and distinction of such offices to
+ those who like them. For my part, I abominate all honourable respectable
+ toils, trials, and tribulations of every kind whatsoever. It is quite as
+ much as I can do to take care of myself, without taking care of ships,
+ barques, brigs, schooners, and what not. And as for going as cook,—though
+ I confess there is considerable glory in that, a cook being a sort of
+ officer on ship-board—yet, somehow, I never fancied broiling fowls;—though
+ once broiled, judiciously buttered, and judgmatically salted and peppered,
+ there is no one who will speak more respectfully, not to say
+ reverentially, of a broiled fowl than I will. It is out of the idolatrous
+ dotings of the old Egyptians upon broiled ibis and roasted river horse,
+ that you see the mummies of those creatures in their huge bake-houses the
+ pyramids.
+ </p>
+ <p>
+ No, when I go to sea, I go as a simple sailor, right before the mast,
+ plumb down into the forecastle, aloft there to the royal mast-head. True,
+ they rather order me about some, and make me jump from spar to spar, like
+ a grasshopper in a May meadow. And at first, this sort of thing is
+ unpleasant enough. It touches one's sense of honour, particularly if you
+ come of an old established family in the land, the Van Rensselaers, or
+ Randolphs, or Hardicanutes. And more than all, if just previous to putting
+ your hand into the tar-pot, you have been lording it as a country
+ schoolmaster, making the tallest boys stand in awe of you. The transition
+ is a keen one, I assure you, from a schoolmaster to a sailor, and requires
+ a strong decoction of Seneca and the Stoics to enable you to grin and bear
+ it. But even this wears off in time.
+ </p>
+ <p>
+ What of it, if some old hunks of a sea-captain orders me to get a broom
+ and sweep down the decks? What does that indignity amount to, weighed, I
+ mean, in the scales of the New Testament? Do you think the archangel
+ Gabriel thinks anything the less of me, because I promptly and
+ respectfully obey that old hunks in that particular instance? Who ain't a
+ slave? Tell me that. Well, then, however the old sea-captains may order me
+ about—however they may thump and punch me about, I have the
+ satisfaction of knowing that it is all right; that everybody else is one
+ way or other served in much the same way—either in a physical or
+ metaphysical point of view, that is; and so the universal thump is passed
+ round, and all hands should rub each other's shoulder-blades, and be
+ content.
+ </p>
+ <p>
+ Again, I always go to sea as a sailor, because they make a point of paying
+ me for my trouble, whereas they never pay passengers a single penny that I
+ ever heard of. On the contrary, passengers themselves must pay. And there
+ is all the difference in the world between paying and being paid. The act
+ of paying is perhaps the most uncomfortable infliction that the two
+ orchard thieves entailed upon us. But <i>being paid</i>,—what will compare
+ with it? The urbane activity with which a man receives money is really
+ marvellous, considering that we so earnestly believe money to be the root
+ of all earthly ills, and that on no account can a monied man enter heaven.
+ Ah! how cheerfully we consign ourselves to perdition!
+ </p>
+ <p>
+ Finally, I always go to sea as a sailor, because of the wholesome exercise
+ and pure air of the fore-castle deck. For as in this world, head winds are
+ far more prevalent than winds from astern (that is, if you never violate
+ the Pythagorean maxim), so for the most part the Commodore on the
+ quarter-deck gets his atmosphere at second hand from the sailors on the
+ forecastle. He thinks he breathes it first; but not so. In much the same
+ way do the commonalty lead their leaders in many other things, at the same
+ time that the leaders little suspect it. But wherefore it was that after
+ having repeatedly smelt the sea as a merchant sailor, I should now take it
+ into my head to go on a whaling voyage; this the invisible police officer
+ of the Fates, who has the constant surveillance of me, and secretly dogs
+ me, and influences me in some unaccountable way—he can better answer
+ than any one else. And, doubtless, my going on this whaling voyage, formed
+ part of the grand programme of Providence that was drawn up a long time
+ ago. It came in as a sort of brief interlude and solo between more
+ extensive performances. I take it that this part of the bill must have run
+ something like this:
+ </p>
+ <p>
+ "<i>Grand Contested Election for the Presidency of the United States.</i>
+ "WHALING VOYAGE BY ONE ISHMAEL. "BLOODY BATTLE IN AFFGHANISTAN."
+ </p>
+ <p>
+ Though I cannot tell why it was exactly that those stage managers, the
+ Fates, put me down for this shabby part of a whaling voyage, when others
+ were set down for magnificent parts in high tragedies, and short and easy
+ parts in genteel comedies, and jolly parts in farces—though I cannot
+ tell why this was exactly; yet, now that I recall all the circumstances, I
+ think I can see a little into the springs and motives which being
+ cunningly presented to me under various disguises, induced me to set about
+ performing the part I did, besides cajoling me into the delusion that it
+ was a choice resulting from my own unbiased freewill and discriminating
+ judgment.
+ </p>
+ <p>
+ Chief among these motives was the overwhelming idea of the great whale
+ himself. Such a portentous and mysterious monster roused all my curiosity.
+ Then the wild and distant seas where he rolled his island bulk; the
+ undeliverable, nameless perils of the whale; these, with all the attending
+ marvels of a thousand Patagonian sights and sounds, helped to sway me to
+ my wish. With other men, perhaps, such things would not have been
+ inducements; but as for me, I am tormented with an everlasting itch for
+ things remote. I love to sail forbidden seas, and land on barbarous
+ coasts. Not ignoring what is good, I am quick to perceive a horror, and
+ could still be social with it—would they let me—since it is
+ but well to be on friendly terms with all the inmates of the place one
+ lodges in.
+ </p>
+ <p>
+ By reason of these things, then, the whaling voyage was welcome; the great
+ flood-gates of the wonder-world swung open, and in the wild conceits that
+ swayed me to my purpose, two and two there floated into my inmost soul,
+ endless processions of the whale, and, mid most of them all, one grand
+ hooded phantom, like a snow hill in the air.
+ </p>
+</body>
+</html>