import FIREFOX_52_6_0esr_RELEASE from mozilla-esr52 hg repo

This commit is contained in:
Roy Tam 2018-01-19 03:59:58 +08:00
commit dcd9973243
150858 changed files with 23884658 additions and 0 deletions

View file

@ -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"
}
};

View file

@ -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;
}
};

View file

@ -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");
}
};

View file

@ -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 : "";
}
};

View file

@ -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']

View file

@ -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"
}
};

View file

@ -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$/, ""))};
});
}
};

View file

@ -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]

View file

@ -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);
});
});

View file

@ -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");
});
});

View file

@ -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");
});
});

View file

@ -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");
});
});

View file

@ -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);
});
});

View file

@ -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);
}

View file

@ -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>

View file

@ -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>