diff options
Diffstat (limited to 'toolkit/components/narrate')
-rw-r--r-- | toolkit/components/narrate/.eslintrc.js | 94 | ||||
-rw-r--r-- | toolkit/components/narrate/NarrateControls.jsm | 343 | ||||
-rw-r--r-- | toolkit/components/narrate/Narrator.jsm | 464 | ||||
-rw-r--r-- | toolkit/components/narrate/VoiceSelect.jsm | 299 | ||||
-rw-r--r-- | toolkit/components/narrate/moz.build | 13 | ||||
-rw-r--r-- | toolkit/components/narrate/test/.eslintrc.js | 23 | ||||
-rw-r--r-- | toolkit/components/narrate/test/NarrateTestUtils.jsm | 148 | ||||
-rw-r--r-- | toolkit/components/narrate/test/browser.ini | 12 | ||||
-rw-r--r-- | toolkit/components/narrate/test/browser_narrate.js | 137 | ||||
-rw-r--r-- | toolkit/components/narrate/test/browser_narrate_disable.js | 37 | ||||
-rw-r--r-- | toolkit/components/narrate/test/browser_narrate_language.js | 73 | ||||
-rw-r--r-- | toolkit/components/narrate/test/browser_voiceselect.js | 112 | ||||
-rw-r--r-- | toolkit/components/narrate/test/browser_word_highlight.js | 69 | ||||
-rw-r--r-- | toolkit/components/narrate/test/head.js | 87 | ||||
-rw-r--r-- | toolkit/components/narrate/test/inferno.html | 238 | ||||
-rw-r--r-- | toolkit/components/narrate/test/moby_dick.html | 218 |
16 files changed, 2367 insertions, 0 deletions
diff --git a/toolkit/components/narrate/.eslintrc.js b/toolkit/components/narrate/.eslintrc.js new file mode 100644 index 000000000..b2d443575 --- /dev/null +++ b/toolkit/components/narrate/.eslintrc.js @@ -0,0 +1,94 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../../.eslintrc.js" + ], + + "globals": { + "Components": true, + "dump": true, + "Iterator": true + }, + + "env": { "browser": true }, + + "rules": { + // Mozilla stuff + "mozilla/no-aArgs": "warn", + "mozilla/reject-importGlobalProperties": "warn", + "mozilla/var-only-at-top-level": "warn", + + "block-scoped-var": "error", + "brace-style": ["warn", "1tbs", {"allowSingleLine": false}], + "camelcase": "warn", + "comma-dangle": "off", + "comma-spacing": ["warn", {"before": false, "after": true}], + "comma-style": ["warn", "last"], + "complexity": "warn", + "consistent-return": "error", + "curly": "error", + "dot-location": ["warn", "property"], + "dot-notation": "error", + "eol-last": "error", + "generator-star-spacing": ["warn", "after"], + "indent": ["warn", 2, {"SwitchCase": 1}], + "key-spacing": ["warn", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": "warn", + "max-len": ["warn", 80, 2, {"ignoreUrls": true}], + "max-nested-callbacks": ["error", 3], + "new-cap": ["error", {"capIsNew": false}], + "new-parens": "error", + "no-array-constructor": "error", + "no-cond-assign": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-else-return": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-semi": "warn", + "no-fallthrough": "error", + "no-inline-comments": "warn", + "no-lonely-if": "error", + "no-mixed-spaces-and-tabs": "error", + "no-multi-spaces": "warn", + "no-multi-str": "warn", + "no-multiple-empty-lines": ["warn", {"max": 1}], + "no-native-reassign": "error", + "no-nested-ternary": "error", + "no-redeclare": "error", + "no-return-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "warn", + "no-shadow-restricted-names": "error", + "no-spaced-func": "warn", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-vars": "error", + "no-with": "error", + "padded-blocks": ["warn", "never"], + "quotes": ["warn", "double", "avoid-escape"], + "semi": ["warn", "always"], + "semi-spacing": ["warn", {"before": false, "after": true}], + "space-before-blocks": ["warn", "always"], + "space-before-function-paren": ["warn", "never"], + "space-in-parens": ["warn", "never"], + "space-infix-ops": ["warn", {"int32Hint": true}], + "space-unary-ops": ["warn", { "words": true, "nonwords": false }], + "spaced-comment": ["warn", "always"], + "strict": ["error", "global"], + "use-isnan": "error", + "valid-typeof": "error", + "yoda": "error" + } +}; diff --git a/toolkit/components/narrate/NarrateControls.jsm b/toolkit/components/narrate/NarrateControls.jsm new file mode 100644 index 000000000..7d8794b18 --- /dev/null +++ b/toolkit/components/narrate/NarrateControls.jsm @@ -0,0 +1,343 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/narrate/VoiceSelect.jsm"); +Cu.import("resource://gre/modules/narrate/Narrator.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AsyncPrefs.jsm"); +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm"); + +this.EXPORTED_SYMBOLS = ["NarrateControls"]; + +var gStrings = Services.strings.createBundle("chrome://global/locale/narrate.properties"); + +function NarrateControls(mm, win) { + this._mm = mm; + this._winRef = Cu.getWeakReference(win); + + win.addEventListener("unload", this); + + // Append content style sheet in document head + let style = win.document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/skin/narrate.css"; + win.document.head.appendChild(style); + + function localize(pieces, ...substitutions) { + let result = pieces[0]; + for (let i = 0; i < substitutions.length; ++i) { + result += gStrings.GetStringFromName(substitutions[i]) + pieces[i + 1]; + } + return result; + } + + let dropdown = win.document.createElement("ul"); + dropdown.className = "dropdown"; + dropdown.id = "narrate-dropdown"; + // We need inline svg here for the animation to work (bug 908634 & 1190881). + // The style animation can't be scoped (bug 830056). + dropdown.innerHTML = + localize`<style scoped> + @import url("chrome://global/skin/narrateControls.css"); + </style> + <li> + <button class="dropdown-toggle button" id="narrate-toggle" + title="${"narrate"}" hidden> + <svg xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="24" height="24" viewBox="0 0 24 24"> + <style> + @keyframes grow { + 0% { transform: scaleY(1); } + 15% { transform: scaleY(1.5); } + 15% { transform: scaleY(1.5); } + 30% { transform: scaleY(1); } + 100% { transform: scaleY(1); } + } + + #waveform > rect { + fill: #808080; + } + + .speaking #waveform > rect { + fill: #58bf43; + transform-box: fill-box; + transform-origin: 50% 50%; + animation-name: grow; + animation-duration: 1750ms; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + #waveform > rect:nth-child(2) { animation-delay: 250ms; } + #waveform > rect:nth-child(3) { animation-delay: 500ms; } + #waveform > rect:nth-child(4) { animation-delay: 750ms; } + #waveform > rect:nth-child(5) { animation-delay: 1000ms; } + #waveform > rect:nth-child(6) { animation-delay: 1250ms; } + #waveform > rect:nth-child(7) { animation-delay: 1500ms; } + + </style> + <g id="waveform"> + <rect x="1" y="8" width="2" height="8" rx=".5" ry=".5" /> + <rect x="4" y="5" width="2" height="14" rx=".5" ry=".5" /> + <rect x="7" y="8" width="2" height="8" rx=".5" ry=".5" /> + <rect x="10" y="4" width="2" height="16" rx=".5" ry=".5" /> + <rect x="13" y="2" width="2" height="20" rx=".5" ry=".5" /> + <rect x="16" y="4" width="2" height="16" rx=".5" ry=".5" /> + <rect x="19" y="7" width="2" height="10" rx=".5" ry=".5" /> + </g> + </svg> + </button> + </li> + <li class="dropdown-popup"> + <div id="narrate-control" class="narrate-row"> + <button disabled id="narrate-skip-previous" + title="${"back"}"></button> + <button id="narrate-start-stop" title="${"start"}"></button> + <button disabled id="narrate-skip-next" + title="${"forward"}"></button> + </div> + <div id="narrate-rate" class="narrate-row"> + <input id="narrate-rate-input" value="0" title="${"speed"}" + step="5" max="100" min="-100" type="range"> + </div> + <div id="narrate-voices" class="narrate-row"></div> + <div class="dropdown-arrow"></div> + </li>`; + + this.narrator = new Narrator(win); + + let branch = Services.prefs.getBranch("narrate."); + let selectLabel = gStrings.GetStringFromName("selectvoicelabel"); + this.voiceSelect = new VoiceSelect(win, selectLabel); + this.voiceSelect.element.addEventListener("change", this); + this.voiceSelect.element.id = "voice-select"; + win.speechSynthesis.addEventListener("voiceschanged", this); + dropdown.querySelector("#narrate-voices").appendChild( + this.voiceSelect.element); + + dropdown.addEventListener("click", this, true); + + let rateRange = dropdown.querySelector("#narrate-rate > input"); + rateRange.addEventListener("change", this); + + // The rate is stored as an integer. + rateRange.value = branch.getIntPref("rate"); + + this._setupVoices(); + + let tb = win.document.getElementById("reader-toolbar"); + tb.appendChild(dropdown); +} + +NarrateControls.prototype = { + handleEvent: function(evt) { + switch (evt.type) { + case "change": + if (evt.target.id == "narrate-rate-input") { + this._onRateInput(evt); + } else { + this._onVoiceChange(); + } + break; + case "click": + this._onButtonClick(evt); + break; + case "voiceschanged": + this._setupVoices(); + break; + case "unload": + if (this.narrator.speaking) { + TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this); + } + break; + } + }, + + /** + * Returns true if synth voices are available. + */ + _setupVoices: function() { + return this.narrator.languagePromise.then(language => { + this.voiceSelect.clear(); + let win = this._win; + let voicePrefs = this._getVoicePref(); + let selectedVoice = voicePrefs[language || "default"]; + let comparer = win.Intl ? + (new Intl.Collator()).compare : (a, b) => a.localeCompare(b); + let filter = !Services.prefs.getBoolPref("narrate.filter-voices"); + let options = win.speechSynthesis.getVoices().filter(v => { + return filter || !language || v.lang.split("-")[0] == language; + }).map(v => { + return { + label: this._createVoiceLabel(v), + value: v.voiceURI, + selected: selectedVoice == v.voiceURI + }; + }).sort((a, b) => comparer(a.label, b.label)); + + if (options.length) { + options.unshift({ + label: gStrings.GetStringFromName("defaultvoice"), + value: "automatic", + selected: selectedVoice == "automatic" + }); + this.voiceSelect.addOptions(options); + } + + let narrateToggle = win.document.getElementById("narrate-toggle"); + let histogram = Services.telemetry.getKeyedHistogramById( + "NARRATE_CONTENT_BY_LANGUAGE_2"); + let initial = !this._voicesInitialized; + this._voicesInitialized = true; + + if (initial) { + histogram.add(language, 0); + } + + if (options.length && narrateToggle.hidden) { + // About to show for the first time.. + histogram.add(language, 1); + } + + // We disable this entire feature if there are no available voices. + narrateToggle.hidden = !options.length; + }); + }, + + _getVoicePref: function() { + let voicePref = Services.prefs.getCharPref("narrate.voice"); + try { + return JSON.parse(voicePref); + } catch (e) { + return { default: voicePref }; + } + }, + + _onRateInput: function(evt) { + AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10)); + this.narrator.setRate(this._convertRate(evt.target.value)); + }, + + _onVoiceChange: function() { + let voice = this.voice; + this.narrator.setVoice(voice); + this.narrator.languagePromise.then(language => { + if (language) { + let voicePref = this._getVoicePref(); + voicePref[language || "default"] = voice; + AsyncPrefs.set("narrate.voice", JSON.stringify(voicePref)); + } + }); + }, + + _onButtonClick: function(evt) { + switch (evt.target.id) { + case "narrate-skip-previous": + this.narrator.skipPrevious(); + break; + case "narrate-skip-next": + this.narrator.skipNext(); + break; + case "narrate-start-stop": + if (this.narrator.speaking) { + this.narrator.stop(); + } else { + this._updateSpeechControls(true); + let options = { rate: this.rate, voice: this.voice }; + this.narrator.start(options).then(() => { + this._updateSpeechControls(false); + }, err => { + Cu.reportError(`Narrate failed: ${err}.`); + this._updateSpeechControls(false); + }); + } + break; + } + }, + + _updateSpeechControls: function(speaking) { + let dropdown = this._doc.getElementById("narrate-dropdown"); + dropdown.classList.toggle("keep-open", speaking); + dropdown.classList.toggle("speaking", speaking); + + let startStopButton = this._doc.getElementById("narrate-start-stop"); + startStopButton.title = + gStrings.GetStringFromName(speaking ? "stop" : "start"); + + this._doc.getElementById("narrate-skip-previous").disabled = !speaking; + this._doc.getElementById("narrate-skip-next").disabled = !speaking; + + if (speaking) { + TelemetryStopwatch.start("NARRATE_CONTENT_SPEAKTIME_MS", this); + } else { + TelemetryStopwatch.finish("NARRATE_CONTENT_SPEAKTIME_MS", this); + } + }, + + _createVoiceLabel: function(voice) { + // This is a highly imperfect method of making human-readable labels + // for system voices. Because each platform has a different naming scheme + // for voices, we use a different method for each platform. + switch (Services.appinfo.OS) { + case "WINNT": + // On windows the language is included in the name, so just use the name + return voice.name; + case "Linux": + // On Linux, the name is usually the unlocalized language name. + // Use a localized language name, and have the language tag in + // parenthisis. This is to avoid six languages called "English". + return gStrings.formatStringFromName("voiceLabel", + [this._getLanguageName(voice.lang) || voice.name, voice.lang], 2); + default: + // On Mac the language is not included in the name, find a localized + // language name or show the tag if none exists. + // This is the ideal naming scheme so it is also the "default". + return gStrings.formatStringFromName("voiceLabel", + [voice.name, this._getLanguageName(voice.lang) || voice.lang], 2); + } + }, + + _getLanguageName: function(lang) { + if (!this._langStrings) { + this._langStrings = Services.strings.createBundle( + "chrome://global/locale/languageNames.properties "); + } + + try { + // language tags will be lower case ascii between 2 and 3 characters long. + return this._langStrings.GetStringFromName(lang.match(/^[a-z]{2,3}/)[0]); + } catch (e) { + return ""; + } + }, + + _convertRate: function(rate) { + // We need to convert a relative percentage value to a fraction rate value. + // eg. -100 is half the speed, 100 is twice the speed in percentage, + // 0.5 is half the speed and 2 is twice the speed in fractions. + return Math.pow(Math.abs(rate / 100) + 1, rate < 0 ? -1 : 1); + }, + + get _win() { + return this._winRef.get(); + }, + + get _doc() { + return this._win.document; + }, + + get rate() { + return this._convertRate( + this._doc.getElementById("narrate-rate-input").value); + }, + + get voice() { + return this.voiceSelect.value; + } +}; diff --git a/toolkit/components/narrate/Narrator.jsm b/toolkit/components/narrate/Narrator.jsm new file mode 100644 index 000000000..ade06510e --- /dev/null +++ b/toolkit/components/narrate/Narrator.jsm @@ -0,0 +1,464 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", + "resource:///modules/translation/LanguageDetector.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = [ "Narrator" ]; + +// Maximum time into paragraph when pressing "skip previous" will go +// to previous paragraph and not the start of current one. +const PREV_THRESHOLD = 2000; +// All text-related style rules that we should copy over to the highlight node. +const kTextStylesRules = ["font-family", "font-kerning", "font-size", + "font-size-adjust", "font-stretch", "font-variant", "font-weight", + "line-height", "letter-spacing", "text-orientation", + "text-transform", "word-spacing"]; + +function Narrator(win) { + this._winRef = Cu.getWeakReference(win); + this._inTest = Services.prefs.getBoolPref("narrate.test"); + this._speechOptions = {}; + this._startTime = 0; + this._stopped = false; + + this.languagePromise = new Promise(resolve => { + let detect = () => { + win.document.removeEventListener("AboutReaderContentReady", detect); + let sampleText = this._doc.getElementById( + "moz-reader-content").textContent.substring(0, 60 * 1024); + LanguageDetector.detectLanguage(sampleText).then(result => { + resolve(result.confident ? result.language : null); + }); + }; + + if (win.document.body.classList.contains("loaded")) { + detect(); + } else { + win.document.addEventListener("AboutReaderContentReady", detect); + } + }); +} + +Narrator.prototype = { + get _doc() { + return this._winRef.get().document; + }, + + get _win() { + return this._winRef.get(); + }, + + get _treeWalker() { + if (!this._treeWalkerRef) { + let wu = this._win.QueryInterface( + Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + let nf = this._win.NodeFilter; + + let filter = { + _matches: new Set(), + + // We want high-level elements that have non-empty text nodes. + // For example, paragraphs. But nested anchors and other elements + // are not interesting since their text already appears in their + // parent's textContent. + acceptNode: function(node) { + if (this._matches.has(node.parentNode)) { + // Reject sub-trees of accepted nodes. + return nf.FILTER_REJECT; + } + + if (!/\S/.test(node.textContent)) { + // Reject nodes with no text. + return nf.FILTER_REJECT; + } + + let bb = wu.getBoundsWithoutFlushing(node); + if (!bb.width || !bb.height) { + // Skip non-rendered nodes. We don't reject because a zero-sized + // container can still have visible, "overflowed", content. + return nf.FILTER_SKIP; + } + + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType == c.TEXT_NODE && /\S/.test(c.textContent)) { + // If node has a non-empty text child accept it. + this._matches.add(node); + return nf.FILTER_ACCEPT; + } + } + + return nf.FILTER_SKIP; + } + }; + + this._treeWalkerRef = new WeakMap(); + + // We can't hold a weak reference on the treewalker, because there + // are no other strong references, and it will be GC'ed. Instead, + // we rely on the window's lifetime and use it as a weak reference. + this._treeWalkerRef.set(this._win, + this._doc.createTreeWalker(this._doc.getElementById("container"), + nf.SHOW_ELEMENT, filter, false)); + } + + return this._treeWalkerRef.get(this._win); + }, + + get _timeIntoParagraph() { + let rv = Date.now() - this._startTime; + return rv; + }, + + get speaking() { + return this._win.speechSynthesis.speaking || + this._win.speechSynthesis.pending; + }, + + _getVoice: function(voiceURI) { + if (!this._voiceMap || !this._voiceMap.has(voiceURI)) { + this._voiceMap = new Map( + this._win.speechSynthesis.getVoices().map(v => [v.voiceURI, v])); + } + + return this._voiceMap.get(voiceURI); + }, + + _isParagraphInView: function(paragraph) { + if (!paragraph) { + return false; + } + + let bb = paragraph.getBoundingClientRect(); + return bb.top >= 0 && bb.top < this._win.innerHeight; + }, + + _sendTestEvent: function(eventType, detail) { + let win = this._win; + win.dispatchEvent(new win.CustomEvent(eventType, + { detail: Cu.cloneInto(detail, win.document) })); + }, + + _speakInner: function() { + this._win.speechSynthesis.cancel(); + let tw = this._treeWalker; + let paragraph = tw.currentNode; + if (paragraph == tw.root) { + this._sendTestEvent("paragraphsdone", {}); + return Promise.resolve(); + } + + let utterance = new this._win.SpeechSynthesisUtterance( + paragraph.textContent); + utterance.rate = this._speechOptions.rate; + if (this._speechOptions.voice) { + utterance.voice = this._speechOptions.voice; + } else { + utterance.lang = this._speechOptions.lang; + } + + this._startTime = Date.now(); + + let highlighter = new Highlighter(paragraph); + + if (this._inTest) { + let onTestSynthEvent = e => { + if (e.detail.type == "boundary") { + let args = Object.assign({ utterance }, e.detail.args); + let evt = new this._win.SpeechSynthesisEvent(e.detail.type, args); + utterance.dispatchEvent(evt); + } + }; + + let removeListeners = () => { + this._win.removeEventListener("testsynthevent", onTestSynthEvent); + }; + + this._win.addEventListener("testsynthevent", onTestSynthEvent); + utterance.addEventListener("end", removeListeners); + utterance.addEventListener("error", removeListeners); + } + + return new Promise((resolve, reject) => { + utterance.addEventListener("start", () => { + paragraph.classList.add("narrating"); + let bb = paragraph.getBoundingClientRect(); + if (bb.top < 0 || bb.bottom > this._win.innerHeight) { + paragraph.scrollIntoView({ behavior: "smooth", block: "start"}); + } + + if (this._inTest) { + this._sendTestEvent("paragraphstart", { + voice: utterance.chosenVoiceURI, + rate: utterance.rate, + paragraph: paragraph.textContent, + tag: paragraph.localName + }); + } + }); + + utterance.addEventListener("end", () => { + if (!this._win) { + // page got unloaded, don't do anything. + return; + } + + highlighter.remove(); + paragraph.classList.remove("narrating"); + this._startTime = 0; + if (this._inTest) { + this._sendTestEvent("paragraphend", {}); + } + + if (this._stopped) { + // User pressed stopped. + resolve(); + } else { + tw.currentNode = tw.nextNode() || tw.root; + this._speakInner().then(resolve, reject); + } + }); + + utterance.addEventListener("error", () => { + reject("speech synthesis failed"); + }); + + utterance.addEventListener("boundary", e => { + if (e.name != "word") { + // We are only interested in word boundaries for now. + return; + } + + // Match non-whitespace. This isn't perfect, but the most universal + // solution for now. + let reWordBoundary = /\S+/g; + // Match the first word from the boundary event offset. + reWordBoundary.lastIndex = e.charIndex; + let firstIndex = reWordBoundary.exec(paragraph.textContent); + if (firstIndex) { + highlighter.highlight(firstIndex.index, reWordBoundary.lastIndex); + if (this._inTest) { + this._sendTestEvent("wordhighlight", { + start: firstIndex.index, + end: reWordBoundary.lastIndex + }); + } + } + }); + + this._win.speechSynthesis.speak(utterance); + }); + }, + + start: function(speechOptions) { + this._speechOptions = { + rate: speechOptions.rate, + voice: this._getVoice(speechOptions.voice) + }; + + this._stopped = false; + return this.languagePromise.then(language => { + if (!this._speechOptions.voice) { + this._speechOptions.lang = language; + } + + let tw = this._treeWalker; + if (!this._isParagraphInView(tw.currentNode)) { + tw.currentNode = tw.root; + while (tw.nextNode()) { + if (this._isParagraphInView(tw.currentNode)) { + break; + } + } + } + if (tw.currentNode == tw.root) { + tw.nextNode(); + } + + return this._speakInner(); + }); + }, + + stop: function() { + this._stopped = true; + this._win.speechSynthesis.cancel(); + }, + + skipNext: function() { + this._win.speechSynthesis.cancel(); + }, + + skipPrevious: function() { + this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1); + }, + + setRate: function(rate) { + this._speechOptions.rate = rate; + /* repeat current paragraph */ + this._goBackParagraphs(1); + }, + + setVoice: function(voice) { + this._speechOptions.voice = this._getVoice(voice); + /* repeat current paragraph */ + this._goBackParagraphs(1); + }, + + _goBackParagraphs: function(count) { + let tw = this._treeWalker; + for (let i = 0; i < count; i++) { + if (!tw.previousNode()) { + tw.currentNode = tw.root; + } + } + this._win.speechSynthesis.cancel(); + } +}; + +/** + * The Highlighter class is used to highlight a range of text in a container. + * + * @param {nsIDOMElement} container a text container + */ +function Highlighter(container) { + this.container = container; +} + +Highlighter.prototype = { + /** + * Highlight the range within offsets relative to the container. + * + * @param {Number} startOffset the start offset + * @param {Number} endOffset the end offset + */ + highlight: function(startOffset, endOffset) { + let containerRect = this.container.getBoundingClientRect(); + let range = this._getRange(startOffset, endOffset); + let rangeRects = range.getClientRects(); + let win = this.container.ownerDocument.defaultView; + let computedStyle = win.getComputedStyle(range.endContainer.parentNode); + let nodes = this._getFreshHighlightNodes(rangeRects.length); + + let textStyle = {}; + for (let textStyleRule of kTextStylesRules) { + textStyle[textStyleRule] = computedStyle[textStyleRule]; + } + + for (let i = 0; i < rangeRects.length; i++) { + let r = rangeRects[i]; + let node = nodes[i]; + + let style = Object.assign({ + "top": `${r.top - containerRect.top + r.height / 2}px`, + "left": `${r.left - containerRect.left + r.width / 2}px`, + "width": `${r.width}px`, + "height": `${r.height}px` + }, textStyle); + + // Enables us to vary the CSS transition on a line change. + node.classList.toggle("newline", style.top != node.dataset.top); + node.dataset.top = style.top; + + // Enables CSS animations. + node.classList.remove("animate"); + win.requestAnimationFrame(() => { + node.classList.add("animate"); + }); + + // Enables alternative word display with a CSS pseudo-element. + node.dataset.word = range.toString(); + + // Apply style + node.style = Object.entries(style).map( + s => `${s[0]}: ${s[1]};`).join(" "); + } + }, + + /** + * Releases reference to container and removes all highlight nodes. + */ + remove: function() { + for (let node of this._nodes) { + node.remove(); + } + + this.container = null; + }, + + /** + * Returns specified amount of highlight nodes. Creates new ones if necessary + * and purges any additional nodes that are not needed. + * + * @param {Number} count number of nodes needed + */ + _getFreshHighlightNodes: function(count) { + let doc = this.container.ownerDocument; + let nodes = Array.from(this._nodes); + + // Remove nodes we don't need anymore (nodes.length - count > 0). + for (let toRemove = 0; toRemove < nodes.length - count; toRemove++) { + nodes.shift().remove(); + } + + // Add additional nodes if we need them (count - nodes.length > 0). + for (let toAdd = 0; toAdd < count - nodes.length; toAdd++) { + let node = doc.createElement("div"); + node.className = "narrate-word-highlight"; + this.container.appendChild(node); + nodes.push(node); + } + + return nodes; + }, + + /** + * Create and return a range object with the start and end offsets relative + * to the container node. + * + * @param {Number} startOffset the start offset + * @param {Number} endOffset the end offset + */ + _getRange: function(startOffset, endOffset) { + let doc = this.container.ownerDocument; + let i = 0; + let treeWalker = doc.createTreeWalker( + this.container, doc.defaultView.NodeFilter.SHOW_TEXT); + let node = treeWalker.nextNode(); + + function _findNodeAndOffset(offset) { + do { + let length = node.data.length; + if (offset >= i && offset <= i + length) { + return [node, offset - i]; + } + i += length; + } while ((node = treeWalker.nextNode())); + + // Offset is out of bounds, return last offset of last node. + node = treeWalker.lastChild(); + return [node, node.data.length]; + } + + let range = doc.createRange(); + range.setStart(..._findNodeAndOffset(startOffset)); + range.setEnd(..._findNodeAndOffset(endOffset)); + + return range; + }, + + /* + * Get all existing highlight nodes for container. + */ + get _nodes() { + return this.container.querySelectorAll(".narrate-word-highlight"); + } +}; diff --git a/toolkit/components/narrate/VoiceSelect.jsm b/toolkit/components/narrate/VoiceSelect.jsm new file mode 100644 index 000000000..b283a06b3 --- /dev/null +++ b/toolkit/components/narrate/VoiceSelect.jsm @@ -0,0 +1,299 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = ["VoiceSelect"]; + +function VoiceSelect(win, label) { + this._winRef = Cu.getWeakReference(win); + + let element = win.document.createElement("div"); + element.classList.add("voiceselect"); + element.innerHTML = + `<button class="select-toggle" aria-controls="voice-options"> + <span class="label">${label}</span> <span class="current-voice"></span> + </button> + <div class="options" id="voice-options" role="listbox"></div>`; + + this._elementRef = Cu.getWeakReference(element); + + let button = this.selectToggle; + button.addEventListener("click", this); + button.addEventListener("keypress", this); + + let listbox = this.listbox; + listbox.addEventListener("click", this); + listbox.addEventListener("mousemove", this); + listbox.addEventListener("keypress", this); + listbox.addEventListener("wheel", this, true); + + win.addEventListener("resize", () => { + this._updateDropdownHeight(); + }); +} + +VoiceSelect.prototype = { + add: function(label, value) { + let option = this._doc.createElement("button"); + option.dataset.value = value; + option.classList.add("option"); + option.tabIndex = "-1"; + option.setAttribute("role", "option"); + option.textContent = label; + this.listbox.appendChild(option); + return option; + }, + + addOptions: function(options) { + let selected = null; + for (let option of options) { + if (option.selected) { + selected = this.add(option.label, option.value); + } else { + this.add(option.label, option.value); + } + } + + this._select(selected || this.options[0], true); + }, + + clear: function() { + this.listbox.innerHTML = ""; + }, + + toggleList: function(force, focus = true) { + if (this.element.classList.toggle("open", force)) { + if (focus) { + (this.selected || this.options[0]).focus(); + } + + this._updateDropdownHeight(true); + this.listbox.setAttribute("aria-expanded", true); + this._win.addEventListener("focus", this, true); + } else { + if (focus) { + this.element.querySelector(".select-toggle").focus(); + } + + this.listbox.setAttribute("aria-expanded", false); + this._win.removeEventListener("focus", this, true); + } + }, + + handleEvent: function(evt) { + let target = evt.target; + + switch (evt.type) { + case "click": + if (target.classList.contains("option")) { + if (!target.classList.contains("selected")) { + this.selected = target; + } + + this.toggleList(false); + } else if (target.classList.contains("select-toggle")) { + this.toggleList(); + } + break; + + case "mousemove": + this.listbox.classList.add("hovering"); + break; + + case "keypress": + if (target.classList.contains("select-toggle")) { + if (evt.altKey) { + this.toggleList(true); + } else { + this._keyPressedButton(evt); + } + } else { + this.listbox.classList.remove("hovering"); + this._keyPressedInBox(evt); + } + break; + + case "wheel": + // Don't let wheel events bubble to document. It will scroll the page + // and close the entire narrate dialog. + evt.stopPropagation(); + break; + + case "focus": + if (!target.closest(".voiceselect")) { + this.toggleList(false, false); + } + break; + } + }, + + _getPagedOption: function(option, up) { + let height = elem => elem.getBoundingClientRect().height; + let listboxHeight = height(this.listbox); + + let next = option; + for (let delta = 0; delta < listboxHeight; delta += height(next)) { + let sibling = up ? next.previousElementSibling : next.nextElementSibling; + if (!sibling) { + break; + } + + next = sibling; + } + + return next; + }, + + _keyPressedButton: function(evt) { + if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) { + this.toggleList(true); + return; + } + + let toSelect; + switch (evt.key) { + case "PageUp": + case "ArrowUp": + toSelect = this.selected.previousElementSibling; + break; + case "PageDown": + case "ArrowDown": + toSelect = this.selected.nextElementSibling; + break; + case "Home": + toSelect = this.selected.parentNode.firstElementChild; + break; + case "End": + toSelect = this.selected.parentNode.lastElementChild; + break; + } + + if (toSelect && toSelect.classList.contains("option")) { + evt.preventDefault(); + this.selected = toSelect; + } + }, + + _keyPressedInBox: function(evt) { + let toFocus; + let cur = this._doc.activeElement; + + switch (evt.key) { + case "ArrowUp": + toFocus = cur.previousElementSibling || this.listbox.lastElementChild; + break; + case "ArrowDown": + toFocus = cur.nextElementSibling || this.listbox.firstElementChild; + break; + case "PageUp": + toFocus = this._getPagedOption(cur, true); + break; + case "PageDown": + toFocus = this._getPagedOption(cur, false); + break; + case "Home": + toFocus = cur.parentNode.firstElementChild; + break; + case "End": + toFocus = cur.parentNode.lastElementChild; + break; + case "Escape": + this.toggleList(false); + break; + } + + if (toFocus && toFocus.classList.contains("option")) { + evt.preventDefault(); + toFocus.focus(); + } + }, + + _select: function(option, suppressEvent = false) { + let oldSelected = this.selected; + if (oldSelected) { + oldSelected.removeAttribute("aria-selected"); + oldSelected.classList.remove("selected"); + } + + if (option) { + option.setAttribute("aria-selected", true); + option.classList.add("selected"); + this.element.querySelector(".current-voice").textContent = + option.textContent; + } + + if (!suppressEvent) { + let evt = this.element.ownerDocument.createEvent("Event"); + evt.initEvent("change", true, true); + this.element.dispatchEvent(evt); + } + }, + + _updateDropdownHeight: function(now) { + let updateInner = () => { + let winHeight = this._win.innerHeight; + let listbox = this.listbox; + let listboxTop = listbox.getBoundingClientRect().top; + listbox.style.maxHeight = (winHeight - listboxTop - 10) + "px"; + }; + + if (now) { + updateInner(); + } else if (!this._pendingDropdownUpdate) { + this._pendingDropdownUpdate = true; + this._win.requestAnimationFrame(() => { + updateInner(); + delete this._pendingDropdownUpdate; + }); + } + }, + + _getOptionFromValue: function(value) { + return Array.from(this.options).find(o => o.dataset.value === value); + }, + + get element() { + return this._elementRef.get(); + }, + + get listbox() { + return this._elementRef.get().querySelector(".options"); + }, + + get selectToggle() { + return this._elementRef.get().querySelector(".select-toggle"); + }, + + get _win() { + return this._winRef.get(); + }, + + get _doc() { + return this._win.document; + }, + + set selected(option) { + this._select(option); + }, + + get selected() { + return this.element.querySelector(".options > .option.selected"); + }, + + get options() { + return this.element.querySelectorAll(".options > .option"); + }, + + set value(value) { + this._select(this._getOptionFromValue(value)); + }, + + get value() { + let selected = this.selected; + return selected ? selected.dataset.value : ""; + } +}; diff --git a/toolkit/components/narrate/moz.build b/toolkit/components/narrate/moz.build new file mode 100644 index 000000000..c5597c369 --- /dev/null +++ b/toolkit/components/narrate/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES.narrate = [ + 'NarrateControls.jsm', + 'Narrator.jsm', + 'VoiceSelect.jsm' +] + +BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] diff --git a/toolkit/components/narrate/test/.eslintrc.js b/toolkit/components/narrate/test/.eslintrc.js new file mode 100644 index 000000000..5ff0bae7e --- /dev/null +++ b/toolkit/components/narrate/test/.eslintrc.js @@ -0,0 +1,23 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../.eslintrc.js" + ], + + "globals": { + "is": true, + "isnot": true, + "ok": true, + "NarrateTestUtils": true, + "content": true, + "ContentTaskUtils": true, + "ContentTask": true, + "BrowserTestUtils": true, + "gBrowser": true, + }, + + "rules": { + "mozilla/import-headjs-globals": "warn" + } +}; diff --git a/toolkit/components/narrate/test/NarrateTestUtils.jsm b/toolkit/components/narrate/test/NarrateTestUtils.jsm new file mode 100644 index 000000000..b782f66c9 --- /dev/null +++ b/toolkit/components/narrate/test/NarrateTestUtils.jsm @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://testing-common/ContentTaskUtils.jsm"); + +this.EXPORTED_SYMBOLS = [ "NarrateTestUtils" ]; + +this.NarrateTestUtils = { + TOGGLE: "#narrate-toggle", + POPUP: "#narrate-dropdown .dropdown-popup", + VOICE_SELECT: "#narrate-voices .select-toggle", + VOICE_OPTIONS: "#narrate-voices .options", + VOICE_SELECTED: "#narrate-voices .options .option.selected", + VOICE_SELECT_LABEL: "#narrate-voices .select-toggle .current-voice", + RATE: "#narrate-rate-input", + START: "#narrate-dropdown:not(.speaking) #narrate-start-stop", + STOP: "#narrate-dropdown.speaking #narrate-start-stop", + BACK: "#narrate-skip-previous", + FORWARD: "#narrate-skip-next", + + isVisible: function(element) { + let style = element.ownerDocument.defaultView.getComputedStyle(element, ""); + if (style.display == "none") { + return false; + } else if (style.visibility != "visible") { + return false; + } else if (style.display == "-moz-popup" && element.state != "open") { + return false; + } + + // Hiding a parent element will hide all its children + if (element.parentNode != element.ownerDocument) { + return this.isVisible(element.parentNode); + } + + return true; + }, + + isStoppedState: function(window, ok) { + let $ = window.document.querySelector.bind(window.document); + ok($(this.BACK).disabled, "back button is disabled"); + ok($(this.FORWARD).disabled, "forward button is disabled"); + ok(!!$(this.START), "start button is showing"); + ok(!$(this.STOP), "stop button is hidden"); + // This checks for a localized label. Not the best... + ok($(this.START).title == "Start", "Button tooltip is correct"); + }, + + isStartedState: function(window, ok) { + let $ = window.document.querySelector.bind(window.document); + ok(!$(this.BACK).disabled, "back button is enabled"); + ok(!$(this.FORWARD).disabled, "forward button is enabled"); + ok(!$(this.START), "start button is hidden"); + ok(!!$(this.STOP), "stop button is showing"); + // This checks for a localized label. Not the best... + ok($(this.STOP).title == "Stop", "Button tooltip is correct"); + }, + + selectVoice: function(window, voiceUri) { + if (!this.isVisible(window.document.querySelector(this.VOICE_OPTIONS))) { + window.document.querySelector(this.VOICE_SELECT).click(); + } + + let voiceOption = window.document.querySelector( + `#narrate-voices .option[data-value="${voiceUri}"]`); + + voiceOption.focus(); + voiceOption.click(); + + return voiceOption.classList.contains("selected"); + }, + + getEventUtils: function(window) { + let eventUtils = { + "_EU_Ci": Components.interfaces, + "_EU_Cc": Components.classes, + window: window, + parent: window, + navigator: window.navigator, + KeyboardEvent: window.KeyboardEvent, + KeyEvent: window.KeyEvent + }; + Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", eventUtils); + return eventUtils; + }, + + getReaderReadyPromise: function(window) { + return new Promise(resolve => { + function observeReady(subject, topic) { + if (subject == window) { + Services.obs.removeObserver(observeReady, topic); + resolve(); + } + } + + if (window.document.body.classList.contains("loaded")) { + resolve(); + } else { + Services.obs.addObserver(observeReady, "AboutReader:Ready", false); + } + }); + }, + + waitForNarrateToggle: function(window) { + let toggle = window.document.querySelector(this.TOGGLE); + return ContentTaskUtils.waitForCondition( + () => !toggle.hidden, ""); + }, + + waitForPrefChange: function(pref) { + return new Promise(resolve => { + function observeChange() { + Services.prefs.removeObserver(pref, observeChange); + resolve(Preferences.get(pref)); + } + + Services.prefs.addObserver(pref, observeChange, false); + }); + }, + + sendBoundaryEvent: function(window, name, charIndex) { + let detail = { type: "boundary", args: { name, charIndex } }; + window.dispatchEvent(new window.CustomEvent("testsynthevent", + { detail: detail })); + }, + + isWordHighlightGone: function(window, ok) { + let $ = window.document.querySelector.bind(window.document); + ok(!$(".narrate-word-highlight"), "No more word highlights exist"); + }, + + getWordHighlights: function(window) { + let $$ = window.document.querySelectorAll.bind(window.document); + let nodes = Array.from($$(".narrate-word-highlight")); + return nodes.map(node => { + return { word: node.dataset.word, + left: Number(node.style.left.replace(/px$/, "")), + top: Number(node.style.top.replace(/px$/, ""))}; + }); + } +}; diff --git a/toolkit/components/narrate/test/browser.ini b/toolkit/components/narrate/test/browser.ini new file mode 100644 index 000000000..0f5d694ac --- /dev/null +++ b/toolkit/components/narrate/test/browser.ini @@ -0,0 +1,12 @@ +[DEFAULT] +support-files = + head.js + NarrateTestUtils.jsm + moby_dick.html + +[browser_narrate.js] +[browser_narrate_disable.js] +[browser_narrate_language.js] +support-files = inferno.html +[browser_voiceselect.js] +[browser_word_highlight.js] diff --git a/toolkit/components/narrate/test/browser_narrate.js b/toolkit/components/narrate/test/browser_narrate.js new file mode 100644 index 000000000..b4951ef9f --- /dev/null +++ b/toolkit/components/narrate/test/browser_narrate.js @@ -0,0 +1,137 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals is, isnot, registerCleanupFunction, add_task */ + +"use strict"; + +registerCleanupFunction(teardown); + +add_task(function* testNarrate() { + setup(); + + yield spawnInNewReaderTab(TEST_ARTICLE, function* () { + let TEST_VOICE = "urn:moz-tts:fake-indirect:teresa"; + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + let popup = $(NarrateTestUtils.POPUP); + ok(!NarrateTestUtils.isVisible(popup), "popup is initially hidden"); + + let toggle = $(NarrateTestUtils.TOGGLE); + toggle.click(); + + ok(NarrateTestUtils.isVisible(popup), "popup toggled"); + + let voiceOptions = $(NarrateTestUtils.VOICE_OPTIONS); + ok(!NarrateTestUtils.isVisible(voiceOptions), + "voice options are initially hidden"); + + $(NarrateTestUtils.VOICE_SELECT).click(); + ok(NarrateTestUtils.isVisible(voiceOptions), "voice options pop up"); + + let prefChanged = NarrateTestUtils.waitForPrefChange("narrate.voice"); + ok(NarrateTestUtils.selectVoice(content, TEST_VOICE), + "test voice selected"); + yield prefChanged; + + ok(!NarrateTestUtils.isVisible(voiceOptions), "voice options hidden again"); + + NarrateTestUtils.isStoppedState(content, ok); + + let promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.START).click(); + let speechinfo = (yield promiseEvent).detail; + is(speechinfo.voice, TEST_VOICE, "correct voice is being used"); + let paragraph = speechinfo.paragraph; + + NarrateTestUtils.isStartedState(content, ok); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.FORWARD).click(); + speechinfo = (yield promiseEvent).detail; + is(speechinfo.voice, TEST_VOICE, "same voice is used"); + isnot(speechinfo.paragraph, paragraph, "next paragraph is being spoken"); + + NarrateTestUtils.isStartedState(content, ok); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.BACK).click(); + speechinfo = (yield promiseEvent).detail; + is(speechinfo.paragraph, paragraph, "first paragraph being spoken"); + + NarrateTestUtils.isStartedState(content, ok); + + paragraph = speechinfo.paragraph; + $(NarrateTestUtils.STOP).click(); + yield ContentTaskUtils.waitForCondition( + () => !$(NarrateTestUtils.STOP), "transitioned to stopped state"); + NarrateTestUtils.isStoppedState(content, ok); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.START).click(); + speechinfo = (yield promiseEvent).detail; + is(speechinfo.paragraph, paragraph, "read same paragraph again"); + + NarrateTestUtils.isStartedState(content, ok); + + let eventUtils = NarrateTestUtils.getEventUtils(content); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + prefChanged = NarrateTestUtils.waitForPrefChange("narrate.rate"); + $(NarrateTestUtils.RATE).focus(); + eventUtils.sendKey("UP", content); + let newspeechinfo = (yield promiseEvent).detail; + is(newspeechinfo.paragraph, speechinfo.paragraph, "same paragraph"); + isnot(newspeechinfo.rate, speechinfo.rate, "rate changed"); + yield prefChanged; + + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphend"); + $(NarrateTestUtils.STOP).click(); + yield promiseEvent; + + yield ContentTaskUtils.waitForCondition( + () => !$(NarrateTestUtils.STOP), "transitioned to stopped state"); + NarrateTestUtils.isStoppedState(content, ok); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "scroll"); + content.scrollBy(0, 10); + yield promiseEvent; + ok(!NarrateTestUtils.isVisible(popup), "popup is hidden after scroll"); + + toggle.click(); + ok(NarrateTestUtils.isVisible(popup), "popup is toggled again"); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.START).click(); + yield promiseEvent; + NarrateTestUtils.isStartedState(content, ok); + + promiseEvent = ContentTaskUtils.waitForEvent(content, "scroll"); + content.scrollBy(0, -10); + yield promiseEvent; + ok(NarrateTestUtils.isVisible(popup), "popup stays visible after scroll"); + + toggle.click(); + ok(!NarrateTestUtils.isVisible(popup), "popup is dismissed while speaking"); + NarrateTestUtils.isStartedState(content, ok); + + // Go forward all the way to the end of the article. We should eventually + // stop. + do { + promiseEvent = Promise.race([ + ContentTaskUtils.waitForEvent(content, "paragraphstart"), + ContentTaskUtils.waitForEvent(content, "paragraphsdone")]); + $(NarrateTestUtils.FORWARD).click(); + } while ((yield promiseEvent).type == "paragraphstart"); + + // This is to make sure we are not actively scrolling when the tab closes. + content.scroll(0, 0); + + yield ContentTaskUtils.waitForCondition( + () => !$(NarrateTestUtils.STOP), "transitioned to stopped state"); + NarrateTestUtils.isStoppedState(content, ok); + }); +}); diff --git a/toolkit/components/narrate/test/browser_narrate_disable.js b/toolkit/components/narrate/test/browser_narrate_disable.js new file mode 100644 index 000000000..264815fd1 --- /dev/null +++ b/toolkit/components/narrate/test/browser_narrate_disable.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals registerCleanupFunction, add_task */ + +"use strict"; + +const ENABLE_PREF = "narrate.enabled"; + +registerCleanupFunction(() => { + clearUserPref(ENABLE_PREF); + teardown(); +}); + +add_task(function* testNarratePref() { + setup(); + + yield spawnInNewReaderTab(TEST_ARTICLE, function() { + is(content.document.querySelectorAll(NarrateTestUtils.TOGGLE).length, 1, + "narrate is inserted by default"); + }); + + setBoolPref(ENABLE_PREF, false); + + yield spawnInNewReaderTab(TEST_ARTICLE, function() { + ok(!content.document.querySelector(NarrateTestUtils.TOGGLE), + "narrate is disabled and is not in reader mode"); + }); + + setBoolPref(ENABLE_PREF, true); + + yield spawnInNewReaderTab(TEST_ARTICLE, function() { + is(content.document.querySelectorAll(NarrateTestUtils.TOGGLE).length, 1, + "narrate is re-enabled and appears only once"); + }); +}); diff --git a/toolkit/components/narrate/test/browser_narrate_language.js b/toolkit/components/narrate/test/browser_narrate_language.js new file mode 100644 index 000000000..2542a87d6 --- /dev/null +++ b/toolkit/components/narrate/test/browser_narrate_language.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals is, isnot, registerCleanupFunction, add_task */ + +"use strict"; + +registerCleanupFunction(teardown); + +add_task(function* testVoiceselectDropdownAutoclose() { + setup("automatic", true); + + yield spawnInNewReaderTab(TEST_ARTICLE, function* () { + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + ok(!!$(".option[data-value='urn:moz-tts:fake-direct:bob']"), + "Jamaican English voice available"); + ok(!!$(".option[data-value='urn:moz-tts:fake-direct:lenny']"), + "Canadian English voice available"); + ok(!!$(".option[data-value='urn:moz-tts:fake-direct:amy']"), + "British English voice available"); + + ok(!$(".option[data-value='urn:moz-tts:fake-direct:celine']"), + "Canadian French voice unavailable"); + ok(!$(".option[data-value='urn:moz-tts:fake-direct:julie']"), + "Mexican Spanish voice unavailable"); + + $(NarrateTestUtils.TOGGLE).click(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "popup is toggled"); + + let prefChanged = NarrateTestUtils.waitForPrefChange( + "narrate.voice", "getCharPref"); + NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-direct:lenny"); + let voicePref = JSON.parse(yield prefChanged); + is(voicePref.en, "urn:moz-tts:fake-direct:lenny", "pref set correctly"); + }); +}); + +add_task(function* testVoiceselectDropdownAutoclose() { + setup("automatic", true); + + yield spawnInNewReaderTab(TEST_ITALIAN_ARTICLE, function* () { + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + ok(!!$(".option[data-value='urn:moz-tts:fake-indirect:zanetta']"), + "Italian voice available"); + ok(!!$(".option[data-value='urn:moz-tts:fake-indirect:margherita']"), + "Italian voice available"); + + ok(!$(".option[data-value='urn:moz-tts:fake-direct:bob']"), + "Jamaican English voice available"); + ok(!$(".option[data-value='urn:moz-tts:fake-direct:celine']"), + "Canadian French voice unavailable"); + ok(!$(".option[data-value='urn:moz-tts:fake-direct:julie']"), + "Mexican Spanish voice unavailable"); + + $(NarrateTestUtils.TOGGLE).click(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "popup is toggled"); + + let prefChanged = NarrateTestUtils.waitForPrefChange( + "narrate.voice", "getCharPref"); + NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-indirect:zanetta"); + let voicePref = JSON.parse(yield prefChanged); + is(voicePref.it, "urn:moz-tts:fake-indirect:zanetta", "pref set correctly"); + }); +}); diff --git a/toolkit/components/narrate/test/browser_voiceselect.js b/toolkit/components/narrate/test/browser_voiceselect.js new file mode 100644 index 000000000..0de6528dd --- /dev/null +++ b/toolkit/components/narrate/test/browser_voiceselect.js @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals registerCleanupFunction, add_task, is, isnot */ + +"use strict"; + +registerCleanupFunction(teardown); + +add_task(function* testVoiceselectDropdownAutoclose() { + setup(); + + yield spawnInNewReaderTab(TEST_ARTICLE, function* () { + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + $(NarrateTestUtils.TOGGLE).click(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "popup is toggled"); + + ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)), + "voice options are initially hidden"); + + $(NarrateTestUtils.VOICE_SELECT).click(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)), + "voice options are toggled"); + + $(NarrateTestUtils.TOGGLE).click(); + // A focus will follow a real click. + $(NarrateTestUtils.TOGGLE).focus(); + ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "narrate popup is dismissed"); + + $(NarrateTestUtils.TOGGLE).click(); + // A focus will follow a real click. + $(NarrateTestUtils.TOGGLE).focus(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "narrate popup is showing again"); + ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)), + "voice options are hidden after popup comes back"); + }); +}); + +add_task(function* testVoiceselectLabelChange() { + setup(); + + yield spawnInNewReaderTab(TEST_ARTICLE, function* () { + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + $(NarrateTestUtils.TOGGLE).click(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "popup is toggled"); + + ok(NarrateTestUtils.selectVoice(content, "urn:moz-tts:fake-direct:lenny"), + "voice selected"); + + let selectedOption = $(NarrateTestUtils.VOICE_SELECTED); + let selectLabel = $(NarrateTestUtils.VOICE_SELECT_LABEL); + + is(selectedOption.textContent, selectLabel.textContent, + "new label matches selected voice"); + }); +}); + +add_task(function* testVoiceselectKeyboard() { + setup(); + + yield spawnInNewReaderTab(TEST_ARTICLE, function* () { + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + $(NarrateTestUtils.TOGGLE).click(); + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.POPUP)), + "popup is toggled"); + + let eventUtils = NarrateTestUtils.getEventUtils(content); + + let firstValue = $(NarrateTestUtils.VOICE_SELECTED).dataset.value; + + ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)), + "voice options initially are hidden"); + + $(NarrateTestUtils.VOICE_SELECT).focus(); + + eventUtils.sendKey("DOWN", content); + + yield ContentTaskUtils.waitForCondition( + () => $(NarrateTestUtils.VOICE_SELECTED).dataset.value != firstValue, + "value changed after pressing DOWN key"); + + eventUtils.sendKey("RETURN", content); + + ok(NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)), + "voice options showing after pressing RETURN"); + + eventUtils.sendKey("UP", content); + + eventUtils.sendKey("RETURN", content); + + ok(!NarrateTestUtils.isVisible($(NarrateTestUtils.VOICE_OPTIONS)), + "voice options hidden after pressing RETURN"); + + yield ContentTaskUtils.waitForCondition( + () => $(NarrateTestUtils.VOICE_SELECTED).dataset.value == firstValue, + "value changed back to original after pressing RETURN"); + }); +}); diff --git a/toolkit/components/narrate/test/browser_word_highlight.js b/toolkit/components/narrate/test/browser_word_highlight.js new file mode 100644 index 000000000..bfdbcf48e --- /dev/null +++ b/toolkit/components/narrate/test/browser_word_highlight.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals is, isnot, registerCleanupFunction, add_task */ + +"use strict"; + +registerCleanupFunction(teardown); + +add_task(function* testNarrate() { + setup("urn:moz-tts:fake-indirect:teresa"); + + yield spawnInNewReaderTab(TEST_ARTICLE, function* () { + let $ = content.document.querySelector.bind(content.document); + + yield NarrateTestUtils.waitForNarrateToggle(content); + + let popup = $(NarrateTestUtils.POPUP); + ok(!NarrateTestUtils.isVisible(popup), "popup is initially hidden"); + + let toggle = $(NarrateTestUtils.TOGGLE); + toggle.click(); + + ok(NarrateTestUtils.isVisible(popup), "popup toggled"); + + NarrateTestUtils.isStoppedState(content, ok); + + let promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.START).click(); + let voice = (yield promiseEvent).detail.voice; + is(voice, "urn:moz-tts:fake-indirect:teresa", "double-check voice"); + + // Skip forward to first paragraph. + let details; + do { + promiseEvent = ContentTaskUtils.waitForEvent(content, "paragraphstart"); + $(NarrateTestUtils.FORWARD).click(); + details = (yield promiseEvent).detail; + } while (details.tag != "p"); + + let boundaryPat = /(\s+)\S/g; + let position = { left: 0, top: 0 }; + let text = details.paragraph; + for (let res = boundaryPat.exec(text); res; res = boundaryPat.exec(text)) { + promiseEvent = ContentTaskUtils.waitForEvent(content, "wordhighlight"); + NarrateTestUtils.sendBoundaryEvent(content, "word", res.index); + let { start, end } = (yield promiseEvent).detail; + let nodes = NarrateTestUtils.getWordHighlights(content); + for (let node of nodes) { + // Since this is English we can assume each word is to the right or + // below the previous one. + ok(node.left > position.left || node.top > position.top, + "highlight position is moving"); + position = { left: node.left, top: node.top }; + } + let wordFromOffset = text.substring(start, end); + // XXX: Each node should contain the part of the word it highlights. + // Right now, each node contains the entire word. + let wordFromHighlight = nodes[0].word; + is(wordFromOffset, wordFromHighlight, "Correct word is highlighted"); + } + + $(NarrateTestUtils.STOP).click(); + yield ContentTaskUtils.waitForCondition( + () => !$(NarrateTestUtils.STOP), "transitioned to stopped state"); + NarrateTestUtils.isWordHighlightGone(content, ok); + }); +}); diff --git a/toolkit/components/narrate/test/head.js b/toolkit/components/narrate/test/head.js new file mode 100644 index 000000000..491a3da8d --- /dev/null +++ b/toolkit/components/narrate/test/head.js @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* exported teardown, setup, toggleExtension, + spawnInNewReaderTab, TEST_ARTICLE, TEST_ITALIAN_ARTICLE */ + +"use strict"; + +const TEST_ARTICLE = + "http://example.com/browser/toolkit/components/narrate/test/moby_dick.html"; + +const TEST_ITALIAN_ARTICLE = + "http://example.com/browser/toolkit/components/narrate/test/inferno.html"; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); + +const TEST_PREFS = { + "reader.parse-on-load.enabled": true, + "media.webspeech.synth.enabled": true, + "media.webspeech.synth.test": true, + "narrate.enabled": true, + "narrate.test": true, + "narrate.voice": null, + "narrate.filter-voices": false, +}; + +function setup(voiceUri = "automatic", filterVoices = false) { + let prefs = Object.assign({}, TEST_PREFS, { + "narrate.filter-voices": filterVoices, + "narrate.voice": JSON.stringify({ en: voiceUri }) + }); + + // Set required test prefs. + Object.entries(prefs).forEach(([name, value]) => { + switch (typeof value) { + case "boolean": + setBoolPref(name, value); + break; + case "string": + setCharPref(name, value); + break; + } + }); +} + +function teardown() { + // Reset test prefs. + Object.entries(TEST_PREFS).forEach(pref => { + clearUserPref(pref[0]); + }); +} + +function spawnInNewReaderTab(url, func) { + return BrowserTestUtils.withNewTab( + { gBrowser, + url: `about:reader?url=${encodeURIComponent(url)}` }, + function* (browser) { + yield ContentTask.spawn(browser, null, function* () { + Components.utils.import("chrome://mochitests/content/browser/" + + "toolkit/components/narrate/test/NarrateTestUtils.jsm"); + + yield NarrateTestUtils.getReaderReadyPromise(content); + }); + + yield ContentTask.spawn(browser, null, func); + }); +} + +function setBoolPref(name, value) { + Services.prefs.setBoolPref(name, value); +} + +function setCharPref(name, value) { + Services.prefs.setCharPref(name, value); +} + +function clearUserPref(name) { + Services.prefs.clearUserPref(name); +} diff --git a/toolkit/components/narrate/test/inferno.html b/toolkit/components/narrate/test/inferno.html new file mode 100644 index 000000000..58dfd24df --- /dev/null +++ b/toolkit/components/narrate/test/inferno.html @@ -0,0 +1,238 @@ +<!DOCTYPE html> +<html> +<head> + <title>Inferno - Canto I</title> +</head> +<body> + <h1>Inferno</h1> + <h2>Canto I: Dante nella selva oscura</h2> + <p> + Nel mezzo del cammin di nostra vita<br> + mi ritrovai per una selva oscura,<br> + ché la diritta via era smarrita. + </p> + <p> + Ahi quanto a dir qual era è cosa dura<br> + esta selva selvaggia e aspra e forte<br> + che nel pensier rinova la paura! + </p> + <p> + Tant' è amara che poco è più morte;<br> + ma per trattar del ben ch'i' vi trovai,<br> + dirò de l'altre cose ch'i' v'ho scorte. + </p> + <p> + Io non so ben ridir com' i' v'intrai,<br> + tant' era pien di sonno a quel punto<br> + che la verace via abbandonai. + </p> + <p> + Ma poi ch'i' fui al piè d'un colle giunto,<br> + là dove terminava quella valle<br> + che m'avea di paura il cor compunto, + </p> + <p> + guardai in alto e vidi le sue spalle<br> + vestite già de' raggi del pianeta<br> + che mena dritto altrui per ogne calle. + </p> + <p> + Allor fu la paura un poco queta,<br> + che nel lago del cor m'era durata<br> + la notte ch'i' passai con tanta pieta. + </p> + <p> + E come quei che con lena affannata,<br> + uscito fuor del pelago a la riva,<br> + si volge a l'acqua perigliosa e guata, + </p> + <p> + così l'animo mio, ch'ancor fuggiva,<br> + si volse a retro a rimirar lo passo<br> + che non lasciò già mai persona viva. + </p> + <p> + Poi ch'èi posato un poco il corpo lasso,<br> + ripresi via per la piaggia diserta,<br> + sì che 'l piè fermo sempre era 'l più basso. + </p> + <p> + Ed ecco, quasi al cominciar de l'erta,<br> + una lonza leggiera e presta molto,<br> + che di pel macolato era coverta; + </p> + <p> + e non mi si partia dinanzi al volto,<br> + anzi 'mpediva tanto il mio cammino,<br> + ch'i' fui per ritornar più volte vòlto. + </p> + <p> + Temp' era dal principio del mattino,<br> + e 'l sol montava 'n sù con quelle stelle<br> + ch'eran con lui quando l'amor divino + </p> + <p> + mosse di prima quelle cose belle;<br> + sì ch'a bene sperar m'era cagione<br> + di quella fiera a la gaetta pelle + </p> + <p> + l'ora del tempo e la dolce stagione;<br> + ma non sì che paura non mi desse<br> + la vista che m'apparve d'un leone. + </p> + <p> + Questi parea che contra me venisse<br> + con la test' alta e con rabbiosa fame,<br> + sì che parea che l'aere ne tremesse. + </p> + <p> + Ed una lupa, che di tutte brame<br> + sembiava carca ne la sua magrezza,<br> + e molte genti fé già viver grame, + </p> + <p> + questa mi porse tanto di gravezza<br> + con la paura ch'uscia di sua vista,<br> + ch'io perdei la speranza de l'altezza. + </p> + <p> + E qual è quei che volontieri acquista,<br> + e giugne 'l tempo che perder lo face,<br> + che 'n tutti suoi pensier piange e s'attrista; + </p> + <p> + tal mi fece la bestia sanza pace,<br> + che, venendomi 'ncontro, a poco a poco<br> + mi ripigneva là dove 'l sol tace. + </p> + <p> + Mentre ch'i' rovinava in basso loco,<br> + dinanzi a li occhi mi si fu offerto<br> + chi per lungo silenzio parea fioco. + </p> + <p> + Quando vidi costui nel gran diserto,<br> + «<em>Miserere</em> di me», gridai a lui,<br> + «qual che tu sii, od ombra od omo certo!». + </p> + <p> + Rispuosemi: «Non omo, omo già fui,<br> + e li parenti miei furon lombardi,<br> + mantoani per patrïa ambedui. + </p> + <p> + Nacqui <em>sub Iulio</em>, ancor che fosse tardi,<br> + e vissi a Roma sotto 'l buono Augusto<br> + nel tempo de li dèi falsi e bugiardi. + </p> + <p> + Poeta fui, e cantai di quel giusto<br> + figliuol d'Anchise che venne di Troia,<br> + poi che 'l superbo Ilïón fu combusto. + </p> + <p> + Ma tu perché ritorni a tanta noia?<br> + perché non sali il dilettoso monte<br> + ch'è principio e cagion di tutta gioia?». + </p> + <p> + «Or se' tu quel Virgilio e quella fonte<br> + che spandi di parlar sì largo fiume?»,<br> + rispuos' io lui con vergognosa fronte. + </p> + <p> + «O de li altri poeti onore e lume,<br> + vagliami 'l lungo studio e 'l grande amore<br> + che m'ha fatto cercar lo tuo volume. + </p> + <p> + Tu se' lo mio maestro e 'l mio autore,<br> + tu se' solo colui da cu' io tolsi<br> + lo bello stilo che m'ha fatto onore. + </p> + <p> + Vedi la bestia per cu' io mi volsi;<br> + aiutami da lei, famoso saggio,<br> + ch'ella mi fa tremar le vene e i polsi». + </p> + <p> + «A te convien tenere altro vïaggio»,<br> + rispuose, poi che lagrimar mi vide,<br> + «se vuo' campar d'esto loco selvaggio; + </p> + <p> + ché questa bestia, per la qual tu gride,<br> + non lascia altrui passar per la sua via,<br> + ma tanto lo 'mpedisce che l'uccide; + </p> + <p> + e ha natura sì malvagia e ria,<br> + che mai non empie la bramosa voglia,<br> + e dopo 'l pasto ha più fame che pria. + </p> + <p> + Molti son li animali a cui s'ammoglia,<br> + e più saranno ancora, infin che 'l veltro<br> + verrà, che la farà morir con doglia. + </p> + <p> + Questi non ciberà terra né peltro,<br> + ma sapïenza, amore e virtute,<br> + e sua nazion sarà tra feltro e feltro. + </p> + <p> + Di quella umile Italia fia salute<br> + per cui morì la vergine Cammilla,<br> + Eurialo e Turno e Niso di ferute. + </p> + <p> + Questi la caccerà per ogne villa,<br> + fin che l'avrà rimessa ne lo 'nferno,<br> + là onde 'nvidia prima dipartilla. + </p> + <p> + Ond' io per lo tuo me' penso e discerno<br> + che tu mi segui, e io sarò tua guida,<br> + e trarrotti di qui per loco etterno; + </p> + <p> + ove udirai le disperate strida,<br> + vedrai li antichi spiriti dolenti,<br> + ch'a la seconda morte ciascun grida; + </p> + <p> + e vederai color che son contenti<br> + nel foco, perché speran di venire<br> + quando che sia a le beate genti. + </p> + <p> + A le quai poi se tu vorrai salire,<br> + anima fia a ciò più di me degna:<br> + con lei ti lascerò nel mio partire; + </p> + <p> + ché quello imperador che là sù regna,<br> + perch' i' fu' ribellante a la sua legge,<br> + non vuol che 'n sua città per me si vegna. + </p> + <p> + In tutte parti impera e quivi regge;<br> + quivi è la sua città e l'alto seggio:<br> + oh felice colui cu' ivi elegge!». + </p> + <p> + E io a lui: «Poeta, io ti richeggio<br> + per quello Dio che tu non conoscesti,<br> + a ciò ch'io fugga questo male e peggio, + </p> + <p> + che tu mi meni là dov' or dicesti,<br> + sì ch'io veggia la porta di san Pietro<br> + e color cui tu fai cotanto mesti». + </p> + <p> + Allor si mosse, e io li tenni dietro. + </p> +</body> +</html> diff --git a/toolkit/components/narrate/test/moby_dick.html b/toolkit/components/narrate/test/moby_dick.html new file mode 100644 index 000000000..0beaa20fd --- /dev/null +++ b/toolkit/components/narrate/test/moby_dick.html @@ -0,0 +1,218 @@ +<!DOCTYPE html> +<html> +<head> +<title>Moby Dick - Chapter 1. Loomings</title> +</head> +<body> + <h1>Moby Dick</h1> + <h2>Chapter 1. Loomings</h2> + <p> + Call me Ishmael. <span>Some <span>years</span></span> ago—never mind how + long precisely—having little or no money in my purse, and nothing particular + to interest me on shore, I thought I would sail about a little and see the + watery part of the world. It is a way I have of driving off the spleen and + regulating the circulation. Whenever I find myself growing grim about the + mouth; whenever it is a damp, drizzly November in my soul; whenever I find + myself involuntarily pausing before coffin warehouses, and bringing up the + rear of every funeral I meet; and especially whenever my hypos get such an + upper hand of me, that it requires a strong moral principle to prevent me + from deliberately stepping into the street, and methodically knocking + people's hats off—then, I account it high time to get to sea as soon as I + can. This is my substitute for pistol and ball. With a philosophical + flourish Cato throws himself upon his sword; I quietly take to the ship. + There is nothing surprising in this. If they but knew it, almost all men in + their degree, some time or other, cherish very nearly the same feelings + towards the ocean with me. + </p> + <p> + There now is your insular city of the Manhattoes, belted round by wharves + as Indian isles by coral reefs—commerce surrounds it with her surf. + Right and left, the streets take you waterward. Its extreme downtown is + the battery, where that noble mole is washed by waves, and cooled by + breezes, which a few hours previous were out of sight of land. Look at the + crowds of water-gazers there. + </p> + <p> + Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears + Hook to Coenties Slip, and from thence, by Whitehall, northward. What do + you see?—Posted like silent sentinels all around the town, stand + thousands upon thousands of mortal men fixed in ocean reveries. Some + leaning against the spiles; some seated upon the pier-heads; some looking + over the bulwarks of ships from China; some high aloft in the rigging, as + if striving to get a still better seaward peep. But these are all + landsmen; of week days pent up in lath and plaster—tied to counters, + nailed to benches, clinched to desks. How then is this? Are the green + fields gone? What do they here? + </p> + <p> + But look! here come more crowds, pacing straight for the water, and + seemingly bound for a dive. Strange! Nothing will content them but the + extremest limit of the land; loitering under the shady lee of yonder + warehouses will not suffice. No. They must get just as nigh the water as + they possibly can without falling in. And there they stand—miles of + them—leagues. Inlanders all, they come from lanes and alleys, + streets and avenues—north, east, south, and west. Yet here they all + unite. Tell me, does the magnetic virtue of the needles of the compasses + of all those ships attract them thither? + </p> + <p> + Once more. Say you are in the country; in some high land of lakes. Take + almost any path you please, and ten to one it carries you down in a dale, + and leaves you there by a pool in the stream. There is magic in it. Let + the most absent-minded of men be plunged in his deepest reveries—stand + that man on his legs, set his feet a-going, and he will infallibly lead + you to water, if water there be in all that region. Should you ever be + athirst in the great American desert, try this experiment, if your caravan + happen to be supplied with a metaphysical professor. Yes, as every one + knows, meditation and water are wedded for ever. + </p> + <p> + But here is an artist. He desires to paint you the dreamiest, shadiest, + quietest, most enchanting bit of romantic landscape in all the valley of + the Saco. What is the chief element he employs? There stand his trees, + each with a hollow trunk, as if a hermit and a crucifix were within; and + here sleeps his meadow, and there sleep his cattle; and up from yonder + cottage goes a sleepy smoke. Deep into distant woodlands winds a mazy way, + reaching to overlapping spurs of mountains bathed in their hill-side blue. + But though the picture lies thus tranced, and though this pine-tree shakes + down its sighs like leaves upon this shepherd's head, yet all were vain, + unless the shepherd's eye were fixed upon the magic stream before him. Go + visit the Prairies in June, when for scores on scores of miles you wade + knee-deep among Tiger-lilies—what is the one charm wanting?—Water—there + is not a drop of water there! Were Niagara but a cataract of sand, would + you travel your thousand miles to see it? Why did the poor poet of + Tennessee, upon suddenly receiving two handfuls of silver, deliberate + whether to buy him a coat, which he sadly needed, or invest his money in a + pedestrian trip to Rockaway Beach? Why is almost every robust healthy boy + with a robust healthy soul in him, at some time or other crazy to go to + sea? Why upon your first voyage as a passenger, did you yourself feel such + a mystical vibration, when first told that you and your ship were now out + of sight of land? Why did the old Persians hold the sea holy? Why did the + Greeks give it a separate deity, and own brother of Jove? Surely all this + is not without meaning. And still deeper the meaning of that story of + Narcissus, who because he could not grasp the tormenting, mild image he + saw in the fountain, plunged into it and was drowned. But that same image, + we ourselves see in all rivers and oceans. It is the image of the + ungraspable phantom of life; and this is the key to it all. + </p> + <p> + Now, when I say that I am in the habit of going to sea whenever I begin to + grow hazy about the eyes, and begin to be over conscious of my lungs, I do + not mean to have it inferred that I ever go to sea as a passenger. For to + go as a passenger you must needs have a purse, and a purse is but a rag + unless you have something in it. Besides, passengers get sea-sick—grow + quarrelsome—don't sleep of nights—do not enjoy themselves + much, as a general thing;—no, I never go as a passenger; nor, though + I am something of a salt, do I ever go to sea as a Commodore, or a + Captain, or a Cook. I abandon the glory and distinction of such offices to + those who like them. For my part, I abominate all honourable respectable + toils, trials, and tribulations of every kind whatsoever. It is quite as + much as I can do to take care of myself, without taking care of ships, + barques, brigs, schooners, and what not. And as for going as cook,—though + I confess there is considerable glory in that, a cook being a sort of + officer on ship-board—yet, somehow, I never fancied broiling fowls;—though + once broiled, judiciously buttered, and judgmatically salted and peppered, + there is no one who will speak more respectfully, not to say + reverentially, of a broiled fowl than I will. It is out of the idolatrous + dotings of the old Egyptians upon broiled ibis and roasted river horse, + that you see the mummies of those creatures in their huge bake-houses the + pyramids. + </p> + <p> + No, when I go to sea, I go as a simple sailor, right before the mast, + plumb down into the forecastle, aloft there to the royal mast-head. True, + they rather order me about some, and make me jump from spar to spar, like + a grasshopper in a May meadow. And at first, this sort of thing is + unpleasant enough. It touches one's sense of honour, particularly if you + come of an old established family in the land, the Van Rensselaers, or + Randolphs, or Hardicanutes. And more than all, if just previous to putting + your hand into the tar-pot, you have been lording it as a country + schoolmaster, making the tallest boys stand in awe of you. The transition + is a keen one, I assure you, from a schoolmaster to a sailor, and requires + a strong decoction of Seneca and the Stoics to enable you to grin and bear + it. But even this wears off in time. + </p> + <p> + What of it, if some old hunks of a sea-captain orders me to get a broom + and sweep down the decks? What does that indignity amount to, weighed, I + mean, in the scales of the New Testament? Do you think the archangel + Gabriel thinks anything the less of me, because I promptly and + respectfully obey that old hunks in that particular instance? Who ain't a + slave? Tell me that. Well, then, however the old sea-captains may order me + about—however they may thump and punch me about, I have the + satisfaction of knowing that it is all right; that everybody else is one + way or other served in much the same way—either in a physical or + metaphysical point of view, that is; and so the universal thump is passed + round, and all hands should rub each other's shoulder-blades, and be + content. + </p> + <p> + Again, I always go to sea as a sailor, because they make a point of paying + me for my trouble, whereas they never pay passengers a single penny that I + ever heard of. On the contrary, passengers themselves must pay. And there + is all the difference in the world between paying and being paid. The act + of paying is perhaps the most uncomfortable infliction that the two + orchard thieves entailed upon us. But <i>being paid</i>,—what will compare + with it? The urbane activity with which a man receives money is really + marvellous, considering that we so earnestly believe money to be the root + of all earthly ills, and that on no account can a monied man enter heaven. + Ah! how cheerfully we consign ourselves to perdition! + </p> + <p> + Finally, I always go to sea as a sailor, because of the wholesome exercise + and pure air of the fore-castle deck. For as in this world, head winds are + far more prevalent than winds from astern (that is, if you never violate + the Pythagorean maxim), so for the most part the Commodore on the + quarter-deck gets his atmosphere at second hand from the sailors on the + forecastle. He thinks he breathes it first; but not so. In much the same + way do the commonalty lead their leaders in many other things, at the same + time that the leaders little suspect it. But wherefore it was that after + having repeatedly smelt the sea as a merchant sailor, I should now take it + into my head to go on a whaling voyage; this the invisible police officer + of the Fates, who has the constant surveillance of me, and secretly dogs + me, and influences me in some unaccountable way—he can better answer + than any one else. And, doubtless, my going on this whaling voyage, formed + part of the grand programme of Providence that was drawn up a long time + ago. It came in as a sort of brief interlude and solo between more + extensive performances. I take it that this part of the bill must have run + something like this: + </p> + <p> + "<i>Grand Contested Election for the Presidency of the United States.</i> + "WHALING VOYAGE BY ONE ISHMAEL. "BLOODY BATTLE IN AFFGHANISTAN." + </p> + <p> + Though I cannot tell why it was exactly that those stage managers, the + Fates, put me down for this shabby part of a whaling voyage, when others + were set down for magnificent parts in high tragedies, and short and easy + parts in genteel comedies, and jolly parts in farces—though I cannot + tell why this was exactly; yet, now that I recall all the circumstances, I + think I can see a little into the springs and motives which being + cunningly presented to me under various disguises, induced me to set about + performing the part I did, besides cajoling me into the delusion that it + was a choice resulting from my own unbiased freewill and discriminating + judgment. + </p> + <p> + Chief among these motives was the overwhelming idea of the great whale + himself. Such a portentous and mysterious monster roused all my curiosity. + Then the wild and distant seas where he rolled his island bulk; the + undeliverable, nameless perils of the whale; these, with all the attending + marvels of a thousand Patagonian sights and sounds, helped to sway me to + my wish. With other men, perhaps, such things would not have been + inducements; but as for me, I am tormented with an everlasting itch for + things remote. I love to sail forbidden seas, and land on barbarous + coasts. Not ignoring what is good, I am quick to perceive a horror, and + could still be social with it—would they let me—since it is + but well to be on friendly terms with all the inmates of the place one + lodges in. + </p> + <p> + By reason of these things, then, the whaling voyage was welcome; the great + flood-gates of the wonder-world swung open, and in the wild conceits that + swayed me to my purpose, two and two there floated into my inmost soul, + endless processions of the whale, and, mid most of them all, one grand + hooded phantom, like a snow hill in the air. + </p> +</body> +</html> |