mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-10-24 08:07:30 +09:00
import FIREFOX_52_6_0esr_RELEASE from mozilla-esr52 hg repo
This commit is contained in:
commit
dcd9973243
150858 changed files with 23884658 additions and 0 deletions
94
toolkit/components/narrate/.eslintrc.js
Normal file
94
toolkit/components/narrate/.eslintrc.js
Normal 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"
|
||||
}
|
||||
};
|
||||
343
toolkit/components/narrate/NarrateControls.jsm
Normal file
343
toolkit/components/narrate/NarrateControls.jsm
Normal 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;
|
||||
}
|
||||
};
|
||||
464
toolkit/components/narrate/Narrator.jsm
Normal file
464
toolkit/components/narrate/Narrator.jsm
Normal 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");
|
||||
}
|
||||
};
|
||||
299
toolkit/components/narrate/VoiceSelect.jsm
Normal file
299
toolkit/components/narrate/VoiceSelect.jsm
Normal 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 : "";
|
||||
}
|
||||
};
|
||||
13
toolkit/components/narrate/moz.build
Normal file
13
toolkit/components/narrate/moz.build
Normal 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']
|
||||
23
toolkit/components/narrate/test/.eslintrc.js
Normal file
23
toolkit/components/narrate/test/.eslintrc.js
Normal 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"
|
||||
}
|
||||
};
|
||||
148
toolkit/components/narrate/test/NarrateTestUtils.jsm
Normal file
148
toolkit/components/narrate/test/NarrateTestUtils.jsm
Normal 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$/, ""))};
|
||||
});
|
||||
}
|
||||
};
|
||||
12
toolkit/components/narrate/test/browser.ini
Normal file
12
toolkit/components/narrate/test/browser.ini
Normal 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]
|
||||
137
toolkit/components/narrate/test/browser_narrate.js
Normal file
137
toolkit/components/narrate/test/browser_narrate.js
Normal 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);
|
||||
});
|
||||
});
|
||||
37
toolkit/components/narrate/test/browser_narrate_disable.js
Normal file
37
toolkit/components/narrate/test/browser_narrate_disable.js
Normal 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");
|
||||
});
|
||||
});
|
||||
73
toolkit/components/narrate/test/browser_narrate_language.js
Normal file
73
toolkit/components/narrate/test/browser_narrate_language.js
Normal 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");
|
||||
});
|
||||
});
|
||||
112
toolkit/components/narrate/test/browser_voiceselect.js
Normal file
112
toolkit/components/narrate/test/browser_voiceselect.js
Normal 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");
|
||||
});
|
||||
});
|
||||
69
toolkit/components/narrate/test/browser_word_highlight.js
Normal file
69
toolkit/components/narrate/test/browser_word_highlight.js
Normal 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);
|
||||
});
|
||||
});
|
||||
87
toolkit/components/narrate/test/head.js
Normal file
87
toolkit/components/narrate/test/head.js
Normal 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);
|
||||
}
|
||||
238
toolkit/components/narrate/test/inferno.html
Normal file
238
toolkit/components/narrate/test/inferno.html
Normal 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>
|
||||
218
toolkit/components/narrate/test/moby_dick.html
Normal file
218
toolkit/components/narrate/test/moby_dick.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue