Initial updates for Reader View.

This commit is contained in:
Ascrod 2018-05-07 19:45:46 -04:00 • committed by Roy Tam
commit 1c494e80a6
27 changed files with 1757 additions and 1786 deletions

View file

@ -16,9 +16,10 @@ this.EXPORTED_SYMBOLS = ["NarrateControls"];
var gStrings = Services.strings.createBundle("chrome://global/locale/narrate.properties");
function NarrateControls(mm, win) {
function NarrateControls(mm, win, languagePromise) {
this._mm = mm;
this._winRef = Cu.getWeakReference(win);
this._languagePromise = languagePromise;
win.addEventListener("unload", this);
@ -37,16 +38,12 @@ function NarrateControls(mm, win) {
}
let dropdown = win.document.createElement("ul");
dropdown.className = "dropdown";
dropdown.id = "narrate-dropdown";
dropdown.className = "dropdown narrate-dropdown";
// We need inline svg here for the animation to work (bug 908634 & 1190881).
// The style animation can't be scoped (bug 830056).
// eslint-disable-next-line no-unsanitized/property
dropdown.innerHTML =
localize`<style scoped>
@import url("chrome://global/skin/narrateControls.css");
</style>
<li>
<button class="dropdown-toggle button" id="narrate-toggle"
localize`<li>
<button class="dropdown-toggle button narrate-toggle"
title="${"narrate"}" hidden>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
@ -60,11 +57,11 @@ function NarrateControls(mm, win) {
100% { transform: scaleY(1); }
}
#waveform > rect {
.waveform > rect {
fill: #808080;
}
.speaking #waveform > rect {
.speaking .waveform > rect {
fill: #58bf43;
transform-box: fill-box;
transform-origin: 50% 50%;
@ -74,15 +71,15 @@ function NarrateControls(mm, win) {
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; }
.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">
<g class="waveform">
<rect x="1" y="8" width="2" height="8" rx=".5" ry=".5" />
<rect x="4" y="5" width="2" height="14" rx=".5" ry=".5" />
<rect x="7" y="8" width="2" height="8" rx=".5" ry=".5" />
@ -95,35 +92,35 @@ function NarrateControls(mm, win) {
</button>
</li>
<li class="dropdown-popup">
<div id="narrate-control" class="narrate-row">
<button disabled id="narrate-skip-previous"
<div class="narrate-row narrate-control">
<button disabled class="narrate-skip-previous"
title="${"back"}"></button>
<button id="narrate-start-stop" title="${"start"}"></button>
<button disabled id="narrate-skip-next"
<button class="narrate-start-stop" title="${"start"}"></button>
<button disabled class="narrate-skip-next"
title="${"forward"}"></button>
</div>
<div id="narrate-rate" class="narrate-row">
<input id="narrate-rate-input" value="0" title="${"speed"}"
<div class="narrate-row narrate-rate">
<input class="narrate-rate-input" value="0" title="${"speed"}"
step="5" max="100" min="-100" type="range">
</div>
<div id="narrate-voices" class="narrate-row"></div>
<div class="narrate-row narrate-voices"></div>
<div class="dropdown-arrow"></div>
</li>`;
this.narrator = new Narrator(win);
this.narrator = new Narrator(win, languagePromise);
let branch = Services.prefs.getBranch("narrate.");
let selectLabel = gStrings.GetStringFromName("selectvoicelabel");
this.voiceSelect = new VoiceSelect(win, selectLabel);
this.voiceSelect.element.addEventListener("change", this);
this.voiceSelect.element.id = "voice-select";
this.voiceSelect.element.classList.add("voice-select");
win.speechSynthesis.addEventListener("voiceschanged", this);
dropdown.querySelector("#narrate-voices").appendChild(
dropdown.querySelector(".narrate-voices").appendChild(
this.voiceSelect.element);
dropdown.addEventListener("click", this, true);
let rateRange = dropdown.querySelector("#narrate-rate > input");
let rateRange = dropdown.querySelector(".narrate-rate > input");
rateRange.addEventListener("change", this);
// The rate is stored as an integer.
@ -131,15 +128,15 @@ function NarrateControls(mm, win) {
this._setupVoices();
let tb = win.document.getElementById("reader-toolbar");
let tb = win.document.querySelector(".reader-toolbar");
tb.appendChild(dropdown);
}
NarrateControls.prototype = {
handleEvent: function(evt) {
handleEvent(evt) {
switch (evt.type) {
case "change":
if (evt.target.id == "narrate-rate-input") {
if (evt.target.classList.contains("narrate-rate-input")) {
this._onRateInput(evt);
} else {
this._onVoiceChange();
@ -162,8 +159,8 @@ NarrateControls.prototype = {
/**
* Returns true if synth voices are available.
*/
_setupVoices: function() {
return this.narrator.languagePromise.then(language => {
_setupVoices() {
return this._languagePromise.then(language => {
this.voiceSelect.clear();
let win = this._win;
let voicePrefs = this._getVoicePref();
@ -190,7 +187,7 @@ NarrateControls.prototype = {
this.voiceSelect.addOptions(options);
}
let narrateToggle = win.document.getElementById("narrate-toggle");
let narrateToggle = win.document.querySelector(".narrate-toggle");
let histogram = Services.telemetry.getKeyedHistogramById(
"NARRATE_CONTENT_BY_LANGUAGE_2");
let initial = !this._voicesInitialized;
@ -210,7 +207,7 @@ NarrateControls.prototype = {
});
},
_getVoicePref: function() {
_getVoicePref() {
let voicePref = Services.prefs.getCharPref("narrate.voice");
try {
return JSON.parse(voicePref);
@ -219,15 +216,15 @@ NarrateControls.prototype = {
}
},
_onRateInput: function(evt) {
_onRateInput(evt) {
AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10));
this.narrator.setRate(this._convertRate(evt.target.value));
},
_onVoiceChange: function() {
_onVoiceChange() {
let voice = this.voice;
this.narrator.setVoice(voice);
this.narrator.languagePromise.then(language => {
this._languagePromise.then(language => {
if (language) {
let voicePref = this._getVoicePref();
voicePref[language || "default"] = voice;
@ -236,42 +233,39 @@ NarrateControls.prototype = {
});
},
_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;
_onButtonClick(evt) {
let classList = evt.target.classList;
if (classList.contains("narrate-skip-previous")) {
this.narrator.skipPrevious();
} else if (classList.contains("narrate-skip-next")) {
this.narrator.skipNext();
} else if (classList.contains("narrate-start-stop")) {
if (this.narrator.speaking) {
this.narrator.stop();
} else {
this._updateSpeechControls(true);
let options = { rate: this.rate, voice: this.voice };
this.narrator.start(options).then(() => {
this._updateSpeechControls(false);
}, err => {
Cu.reportError(`Narrate failed: ${err}.`);
this._updateSpeechControls(false);
});
}
}
},
_updateSpeechControls: function(speaking) {
let dropdown = this._doc.getElementById("narrate-dropdown");
_updateSpeechControls(speaking) {
let dropdown = this._doc.querySelector(".narrate-dropdown");
dropdown.classList.toggle("keep-open", speaking);
dropdown.classList.toggle("speaking", speaking);
let startStopButton = this._doc.getElementById("narrate-start-stop");
let startStopButton = this._doc.querySelector(".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;
this._doc.querySelector(".narrate-skip-previous").disabled = !speaking;
this._doc.querySelector(".narrate-skip-next").disabled = !speaking;
if (speaking) {
TelemetryStopwatch.start("NARRATE_CONTENT_SPEAKTIME_MS", this);
@ -280,7 +274,7 @@ NarrateControls.prototype = {
}
},
_createVoiceLabel: function(voice) {
_createVoiceLabel(voice) {
// This is a highly imperfect method of making human-readable labels
// for system voices. Because each platform has a different naming scheme
// for voices, we use a different method for each platform.
@ -303,7 +297,7 @@ NarrateControls.prototype = {
}
},
_getLanguageName: function(lang) {
_getLanguageName(lang) {
if (!this._langStrings) {
this._langStrings = Services.strings.createBundle(
"chrome://global/locale/languageNames.properties ");
@ -317,7 +311,7 @@ NarrateControls.prototype = {
}
},
_convertRate: function(rate) {
_convertRate(rate) {
// We need to convert a relative percentage value to a fraction rate value.
// eg. -100 is half the speed, 100 is twice the speed in percentage,
// 0.5 is half the speed and 2 is twice the speed in fractions.
@ -334,7 +328,7 @@ NarrateControls.prototype = {
get rate() {
return this._convertRate(
this._doc.getElementById("narrate-rate-input").value);
this._doc.querySelector(".narrate-rate-input").value);
},
get voice() {

View file

@ -8,8 +8,6 @@ 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");
@ -24,29 +22,13 @@ const kTextStylesRules = ["font-family", "font-kerning", "font-size",
"line-height", "letter-spacing", "text-orientation",
"text-transform", "word-spacing"];
function Narrator(win) {
function Narrator(win, languagePromise) {
this._winRef = Cu.getWeakReference(win);
this._languagePromise = languagePromise;
this._inTest = Services.prefs.getBoolPref("narrate.test");
this._speechOptions = {};
this._startTime = 0;
this._stopped = false;
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 = {
@ -71,7 +53,7 @@ Narrator.prototype = {
// 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) {
acceptNode(node) {
if (this._matches.has(node.parentNode)) {
// Reject sub-trees of accepted nodes.
return nf.FILTER_REJECT;
@ -107,7 +89,7 @@ Narrator.prototype = {
// 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"),
this._doc.createTreeWalker(this._doc.querySelector(".container"),
nf.SHOW_ELEMENT, filter, false));
}
@ -124,7 +106,7 @@ Narrator.prototype = {
this._win.speechSynthesis.pending;
},
_getVoice: function(voiceURI) {
_getVoice(voiceURI) {
if (!this._voiceMap || !this._voiceMap.has(voiceURI)) {
this._voiceMap = new Map(
this._win.speechSynthesis.getVoices().map(v => [v.voiceURI, v]));
@ -133,7 +115,7 @@ Narrator.prototype = {
return this._voiceMap.get(voiceURI);
},
_isParagraphInView: function(paragraph) {
_isParagraphInView(paragraph) {
if (!paragraph) {
return false;
}
@ -142,13 +124,13 @@ Narrator.prototype = {
return bb.top >= 0 && bb.top < this._win.innerHeight;
},
_sendTestEvent: function(eventType, detail) {
_sendTestEvent(eventType, detail) {
let win = this._win;
win.dispatchEvent(new win.CustomEvent(eventType,
{ detail: Cu.cloneInto(detail, win.document) }));
},
_speakInner: function() {
_speakInner() {
this._win.speechSynthesis.cancel();
let tw = this._treeWalker;
let paragraph = tw.currentNode;
@ -238,18 +220,12 @@ Narrator.prototype = {
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 (e.charLength) {
highlighter.highlight(e.charIndex, e.charLength);
if (this._inTest) {
this._sendTestEvent("wordhighlight", {
start: firstIndex.index,
end: reWordBoundary.lastIndex
start: e.charIndex,
end: e.charIndex + e.charLength
});
}
}
@ -259,14 +235,14 @@ Narrator.prototype = {
});
},
start: function(speechOptions) {
start(speechOptions) {
this._speechOptions = {
rate: speechOptions.rate,
voice: this._getVoice(speechOptions.voice)
};
this._stopped = false;
return this.languagePromise.then(language => {
return this._languagePromise.then(language => {
if (!this._speechOptions.voice) {
this._speechOptions.lang = language;
}
@ -288,32 +264,32 @@ Narrator.prototype = {
});
},
stop: function() {
stop() {
this._stopped = true;
this._win.speechSynthesis.cancel();
},
skipNext: function() {
skipNext() {
this._win.speechSynthesis.cancel();
},
skipPrevious: function() {
skipPrevious() {
this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1);
},
setRate: function(rate) {
setRate(rate) {
this._speechOptions.rate = rate;
/* repeat current paragraph */
this._goBackParagraphs(1);
},
setVoice: function(voice) {
setVoice(voice) {
this._speechOptions.voice = this._getVoice(voice);
/* repeat current paragraph */
this._goBackParagraphs(1);
},
_goBackParagraphs: function(count) {
_goBackParagraphs(count) {
let tw = this._treeWalker;
for (let i = 0; i < count; i++) {
if (!tw.previousNode()) {
@ -338,13 +314,13 @@ Highlighter.prototype = {
* Highlight the range within offsets relative to the container.
*
* @param {Number} startOffset the start offset
* @param {Number} endOffset the end offset
* @param {Number} length the length in characters of the range
*/
highlight: function(startOffset, endOffset) {
highlight(startOffset, length) {
let containerRect = this.container.getBoundingClientRect();
let range = this._getRange(startOffset, endOffset);
let range = this._getRange(startOffset, startOffset + length);
let rangeRects = range.getClientRects();
let win = this.container.ownerDocument.defaultView;
let win = this.container.ownerGlobal;
let computedStyle = win.getComputedStyle(range.endContainer.parentNode);
let nodes = this._getFreshHighlightNodes(rangeRects.length);
@ -386,7 +362,7 @@ Highlighter.prototype = {
/**
* Releases reference to container and removes all highlight nodes.
*/
remove: function() {
remove() {
for (let node of this._nodes) {
node.remove();
}
@ -400,7 +376,7 @@ Highlighter.prototype = {
*
* @param {Number} count number of nodes needed
*/
_getFreshHighlightNodes: function(count) {
_getFreshHighlightNodes(count) {
let doc = this.container.ownerDocument;
let nodes = Array.from(this._nodes);
@ -427,7 +403,7 @@ Highlighter.prototype = {
* @param {Number} startOffset the start offset
* @param {Number} endOffset the end offset
*/
_getRange: function(startOffset, endOffset) {
_getRange(startOffset, endOffset) {
let doc = this.container.ownerDocument;
let i = 0;
let treeWalker = doc.createTreeWalker(

View file

@ -13,6 +13,7 @@ function VoiceSelect(win, label) {
let element = win.document.createElement("div");
element.classList.add("voiceselect");
// eslint-disable-next-line no-unsanitized/property
element.innerHTML =
`<button class="select-toggle" aria-controls="voice-options">
<span class="label">${label}</span> <span class="current-voice"></span>
@ -37,7 +38,7 @@ function VoiceSelect(win, label) {
}
VoiceSelect.prototype = {
add: function(label, value) {
add(label, value) {
let option = this._doc.createElement("button");
option.dataset.value = value;
option.classList.add("option");
@ -48,7 +49,7 @@ VoiceSelect.prototype = {
return option;
},
addOptions: function(options) {
addOptions(options) {
let selected = null;
for (let option of options) {
if (option.selected) {
@ -61,11 +62,11 @@ VoiceSelect.prototype = {
this._select(selected || this.options[0], true);
},
clear: function() {
clear() {
this.listbox.innerHTML = "";
},
toggleList: function(force, focus = true) {
toggleList(force, focus = true) {
if (this.element.classList.toggle("open", force)) {
if (focus) {
(this.selected || this.options[0]).focus();
@ -84,7 +85,7 @@ VoiceSelect.prototype = {
}
},
handleEvent: function(evt) {
handleEvent(evt) {
let target = evt.target;
switch (evt.type) {
@ -131,7 +132,7 @@ VoiceSelect.prototype = {
}
},
_getPagedOption: function(option, up) {
_getPagedOption(option, up) {
let height = elem => elem.getBoundingClientRect().height;
let listboxHeight = height(this.listbox);
@ -148,7 +149,7 @@ VoiceSelect.prototype = {
return next;
},
_keyPressedButton: function(evt) {
_keyPressedButton(evt) {
if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) {
this.toggleList(true);
return;
@ -178,7 +179,7 @@ VoiceSelect.prototype = {
}
},
_keyPressedInBox: function(evt) {
_keyPressedInBox(evt) {
let toFocus;
let cur = this._doc.activeElement;
@ -212,7 +213,7 @@ VoiceSelect.prototype = {
}
},
_select: function(option, suppressEvent = false) {
_select(option, suppressEvent = false) {
let oldSelected = this.selected;
if (oldSelected) {
oldSelected.removeAttribute("aria-selected");
@ -233,7 +234,7 @@ VoiceSelect.prototype = {
}
},
_updateDropdownHeight: function(now) {
_updateDropdownHeight(now) {
let updateInner = () => {
let winHeight = this._win.innerHeight;
let listbox = this.listbox;
@ -252,7 +253,7 @@ VoiceSelect.prototype = {
}
},
_getOptionFromValue: function(value) {
_getOptionFromValue(value) {
return Array.from(this.options).find(o => o.dataset.value === value);
},

View file

@ -3,7 +3,7 @@
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* This file defines specific rules for print preview when using simplify mode.
* These rules already exist on aboutReaderControls.css, however, we decoupled it
* These rules already exist on aboutReader.css, however, we decoupled it
* from the original file so we don't need to load a bunch of extra queries that
* will not take effect when using the simplify page checkbox. This file defines
* styling for title and author on the header element. */

View file

@ -15,8 +15,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AsyncPrefs", "resource://gre/modules/AsyncPrefs.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NarrateControls", "resource://gre/modules/narrate/NarrateControls.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm");
var gStrings = Services.strings.createBundle("chrome://global/locale/aboutReader.properties");
@ -45,45 +44,49 @@ var AboutReader = function(mm, win, articlePromise) {
.getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
this._article = null;
this._languagePromise = new Promise(resolve => {
this._foundLanguage = resolve;
});
if (articlePromise) {
this._articlePromise = articlePromise;
}
this._headerElementRef = Cu.getWeakReference(doc.getElementById("reader-header"));
this._domainElementRef = Cu.getWeakReference(doc.getElementById("reader-domain"));
this._titleElementRef = Cu.getWeakReference(doc.getElementById("reader-title"));
this._creditsElementRef = Cu.getWeakReference(doc.getElementById("reader-credits"));
this._contentElementRef = Cu.getWeakReference(doc.getElementById("moz-reader-content"));
this._toolbarElementRef = Cu.getWeakReference(doc.getElementById("reader-toolbar"));
this._messageElementRef = Cu.getWeakReference(doc.getElementById("reader-message"));
this._headerElementRef = Cu.getWeakReference(doc.querySelector(".reader-header"));
this._domainElementRef = Cu.getWeakReference(doc.querySelector(".reader-domain"));
this._titleElementRef = Cu.getWeakReference(doc.querySelector(".reader-title"));
this._readTimeElementRef = Cu.getWeakReference(doc.querySelector(".reader-estimated-time"));
this._creditsElementRef = Cu.getWeakReference(doc.querySelector(".reader-credits"));
this._contentElementRef = Cu.getWeakReference(doc.querySelector(".moz-reader-content"));
this._toolbarElementRef = Cu.getWeakReference(doc.querySelector(".reader-toolbar"));
this._messageElementRef = Cu.getWeakReference(doc.querySelector(".reader-message"));
this._containerElementRef = Cu.getWeakReference(doc.querySelector(".container"));
this._scrollOffset = win.pageYOffset;
doc.addEventListener("click", this, false);
doc.addEventListener("click", this);
win.addEventListener("pagehide", this, false);
win.addEventListener("scroll", this, false);
win.addEventListener("resize", this, false);
win.addEventListener("pagehide", this);
win.addEventListener("scroll", this);
win.addEventListener("resize", this);
Services.obs.addObserver(this, "inner-window-destroyed", false);
doc.addEventListener("visibilitychange", this, false);
doc.addEventListener("visibilitychange", this);
this._setupStyleDropdown();
this._setupButton("close-button", this._onReaderClose.bind(this), "aboutReader.toolbar.close");
const gIsFirefoxDesktop = Services.appinfo.ID == "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
if (gIsFirefoxDesktop) {
// we're ready for any external setup, send a signal for that.
this._mm.sendAsyncMessage("Reader:OnSetup");
}
// we're ready for any external setup, send a signal for that.
this._mm.sendAsyncMessage("Reader:OnSetup");
let colorSchemeValues = JSON.parse(Services.prefs.getCharPref("reader.color_scheme.values"));
let colorSchemeOptions = colorSchemeValues.map((value) => {
return { name: gStrings.GetStringFromName("aboutReader.colorScheme." + value),
value: value,
itemClass: value + "-button" };
return {
name: gStrings.GetStringFromName("aboutReader.colorScheme." + value),
value,
itemClass: value + "-button"
};
});
let colorScheme = Services.prefs.getCharPref("reader.color_scheme");
@ -114,7 +117,7 @@ var AboutReader = function(mm, win, articlePromise) {
this._setupLineHeightButtons();
if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
new NarrateControls(mm, win);
new NarrateControls(mm, win, this._languagePromise);
}
this._loadArticle();
@ -146,6 +149,10 @@ AboutReader.prototype = {
return this._titleElementRef.get();
},
get _readTimeElement() {
return this._readTimeElementRef.get();
},
get _creditsElement() {
return this._creditsElementRef.get();
},
@ -162,6 +169,10 @@ AboutReader.prototype = {
return this._messageElementRef.get();
},
get _containerElement() {
return this._containerElementRef.get();
},
get _isToolbarVertical() {
if (this._toolbarVertical !== undefined) {
return this._toolbarVertical;
@ -178,7 +189,7 @@ AboutReader.prototype = {
return _viewId;
},
receiveMessage: function (message) {
receiveMessage (message) {
switch (message.name) {
// Triggered by Android user pressing BACK while the banner font-dropdown is open.
case "Reader:CloseDropdown": {
@ -191,14 +202,14 @@ AboutReader.prototype = {
if (message.data.id && message.data.image &&
!this._doc.getElementById(message.data.id)) {
let btn = this._doc.createElement("button");
btn.setAttribute("class", "button");
btn.setAttribute("class", "button " + message.data.id);
btn.setAttribute("style", "background-image: url('" + message.data.image + "')");
btn.setAttribute("id", message.data.id);
if (message.data.title)
btn.setAttribute("title", message.data.title);
if (message.data.text)
btn.textContent = message.data.text;
let tb = this._doc.getElementById("reader-toolbar");
let tb = this._toolbarElement;
tb.appendChild(btn);
this._setupButton(message.data.id, button => {
this._mm.sendAsyncMessage("Reader:Clicked-" + button.getAttribute("id"), { article: this._article });
@ -220,18 +231,21 @@ AboutReader.prototype = {
}
},
handleEvent: function(aEvent) {
handleEvent(aEvent) {
if (!aEvent.isTrusted)
return;
switch (aEvent.type) {
case "click":
let target = aEvent.target;
if (target.classList.contains('dropdown-toggle')) {
if (target.classList.contains("dropdown-toggle")) {
this._toggleDropdownClicked(aEvent);
} else if (!target.closest('.dropdown-popup')) {
} else if (!target.closest(".dropdown-popup")) {
this._closeDropdowns();
}
if (target.tagName == "A" && !target.classList.contains("reader-domain")) {
this._linkClicked(aEvent);
}
break;
case "scroll":
this._closeDropdowns(true);
@ -243,7 +257,7 @@ AboutReader.prototype = {
this._updateImageMargins();
if (this._isToolbarVertical) {
this._win.setTimeout(() => {
for (let dropdown of this._doc.querySelectorAll('.dropdown.open')) {
for (let dropdown of this._doc.querySelectorAll(".dropdown.open")) {
this._updatePopupPosition(dropdown);
}
}, 0);
@ -271,12 +285,12 @@ AboutReader.prototype = {
}
},
observe: function(subject, topic, data) {
observe(subject, topic, data) {
if (subject.QueryInterface(Ci.nsISupportsPRUint64).data != this._innerWindowId) {
return;
}
Services.obs.removeObserver(this, "inner-window-destroyed", false);
Services.obs.removeObserver(this, "inner-window-destroyed");
this._mm.removeMessageListener("Reader:CloseDropdown", this);
this._mm.removeMessageListener("Reader:AddButton", this);
@ -284,12 +298,12 @@ AboutReader.prototype = {
this._windowUnloaded = true;
},
_onReaderClose: function() {
_onReaderClose() {
ReaderMode.leaveReaderMode(this._mm.docShell, this._win);
},
_setFontSize: function(newFontSize) {
let containerClasses = this._doc.getElementById("container").classList;
_setFontSize(newFontSize) {
let containerClasses = this._containerElement.classList;
if (this._fontSize > 0)
containerClasses.remove("font-size" + this._fontSize);
@ -299,19 +313,19 @@ AboutReader.prototype = {
return AsyncPrefs.set("reader.font_size", this._fontSize);
},
_setupFontSizeButtons: function() {
_setupFontSizeButtons() {
const FONT_SIZE_MIN = 1;
const FONT_SIZE_MAX = 9;
// Sample text shown in Android UI.
let sampleText = this._doc.getElementById("font-size-sample");
let sampleText = this._doc.querySelector(".font-size-sample");
sampleText.textContent = gStrings.GetStringFromName("aboutReader.fontTypeSample");
let currentSize = Services.prefs.getIntPref("reader.font_size");
currentSize = Math.max(FONT_SIZE_MIN, Math.min(FONT_SIZE_MAX, currentSize));
let plusButton = this._doc.getElementById("font-size-plus");
let minusButton = this._doc.getElementById("font-size-minus");
let plusButton = this._doc.querySelector(".plus-button");
let minusButton = this._doc.querySelector(".minus-button");
function updateControls() {
if (currentSize === FONT_SIZE_MIN) {
@ -360,8 +374,8 @@ AboutReader.prototype = {
}, true);
},
_setContentWidth: function(newContentWidth) {
let containerClasses = this._doc.getElementById("container").classList;
_setContentWidth(newContentWidth) {
let containerClasses = this._containerElement.classList;
if (this._contentWidth > 0)
containerClasses.remove("content-width" + this._contentWidth);
@ -371,15 +385,15 @@ AboutReader.prototype = {
return AsyncPrefs.set("reader.content_width", this._contentWidth);
},
_setupContentWidthButtons: function() {
_setupContentWidthButtons() {
const CONTENT_WIDTH_MIN = 1;
const CONTENT_WIDTH_MAX = 9;
let currentContentWidth = Services.prefs.getIntPref("reader.content_width");
currentContentWidth = Math.max(CONTENT_WIDTH_MIN, Math.min(CONTENT_WIDTH_MAX, currentContentWidth));
let plusButton = this._doc.getElementById("content-width-plus");
let minusButton = this._doc.getElementById("content-width-minus");
let plusButton = this._doc.querySelector(".content-width-plus-button");
let minusButton = this._doc.querySelector(".content-width-minus-button");
function updateControls() {
if (currentContentWidth === CONTENT_WIDTH_MIN) {
@ -428,8 +442,8 @@ AboutReader.prototype = {
}, true);
},
_setLineHeight: function(newLineHeight) {
let contentClasses = this._doc.getElementById("moz-reader-content").classList;
_setLineHeight(newLineHeight) {
let contentClasses = this._contentElement.classList;
if (this._lineHeight > 0)
contentClasses.remove("line-height" + this._lineHeight);
@ -439,15 +453,15 @@ AboutReader.prototype = {
return AsyncPrefs.set("reader.line_height", this._lineHeight);
},
_setupLineHeightButtons: function() {
_setupLineHeightButtons() {
const LINE_HEIGHT_MIN = 1;
const LINE_HEIGHT_MAX = 9;
let currentLineHeight = Services.prefs.getIntPref("reader.line_height");
currentLineHeight = Math.max(LINE_HEIGHT_MIN, Math.min(LINE_HEIGHT_MAX, currentLineHeight));
let plusButton = this._doc.getElementById("line-height-plus");
let minusButton = this._doc.getElementById("line-height-minus");
let plusButton = this._doc.querySelector(".line-height-plus-button");
let minusButton = this._doc.querySelector(".line-height-minus-button");
function updateControls() {
if (currentLineHeight === LINE_HEIGHT_MIN) {
@ -496,7 +510,7 @@ AboutReader.prototype = {
}, true);
},
_handleDeviceLight: function(newLux) {
_handleDeviceLight(newLux) {
// Desired size of the this._luxValues array.
let luxValuesSize = 10;
// Add new lux value at the front of the array.
@ -513,7 +527,7 @@ AboutReader.prototype = {
return;
}
// Holds the average of the lux values collected in this._luxValues.
let averageLuxValue = this._totalLux/luxValuesSize;
let averageLuxValue = this._totalLux / luxValuesSize;
this._updateColorScheme(averageLuxValue);
// Pop the oldest value off the array.
@ -522,7 +536,7 @@ AboutReader.prototype = {
this._totalLux -= oldLux;
},
_handleVisibilityChange: function() {
_handleVisibilityChange() {
let colorScheme = Services.prefs.getCharPref("reader.color_scheme");
if (colorScheme != "auto") {
return;
@ -533,19 +547,19 @@ AboutReader.prototype = {
},
// Setup or teardown the ambient light tracking system.
_enableAmbientLighting: function(enable) {
_enableAmbientLighting(enable) {
if (enable) {
this._win.addEventListener("devicelight", this, false);
this._win.addEventListener("devicelight", this);
this._luxValues = [];
this._totalLux = 0;
} else {
this._win.removeEventListener("devicelight", this, false);
this._win.removeEventListener("devicelight", this);
delete this._luxValues;
delete this._totalLux;
}
},
_updateColorScheme: function(luxValue) {
_updateColorScheme(luxValue) {
// Upper bound value for "dark" color scheme beyond which it changes to "light".
let upperBoundDark = 50;
// Lower bound value for "light" color scheme beyond which it changes to "dark".
@ -564,7 +578,7 @@ AboutReader.prototype = {
this._setColorScheme("light");
},
_setColorScheme: function(newColorScheme) {
_setColorScheme(newColorScheme) {
// "auto" is not a real color scheme
if (this._colorScheme === newColorScheme || newColorScheme === "auto")
return;
@ -580,14 +594,14 @@ AboutReader.prototype = {
// Pref values include "dark", "light", and "auto", which automatically switches
// between light and dark color schemes based on the ambient light level.
_setColorSchemePref: function(colorSchemePref) {
_setColorSchemePref(colorSchemePref) {
this._enableAmbientLighting(colorSchemePref === "auto");
this._setColorScheme(colorSchemePref);
AsyncPrefs.set("reader.color_scheme", colorSchemePref);
},
_setFontType: function(newFontType) {
_setFontType(newFontType) {
if (this._fontType === newFontType)
return;
@ -602,20 +616,38 @@ AboutReader.prototype = {
AsyncPrefs.set("reader.font_type", this._fontType);
},
_setSystemUIVisibility: function(visible) {
_setSystemUIVisibility(visible) {
this._mm.sendAsyncMessage("Reader:SystemUIVisibility", { visible: visible });
},
_loadArticle: Task.async(function* () {
_setToolbarVisibility(visible) {
let tb = this._toolbarElement;
if (visible) {
if (tb.style.opacity != "1") {
tb.removeAttribute("hidden");
tb.style.opacity = "1";
}
} else if (tb.style.opacity != "0") {
tb.addEventListener("transitionend", evt => {
if (tb.style.opacity == "0") {
tb.setAttribute("hidden", "");
}
}, { once: true });
tb.style.opacity = "0";
}
},
async _loadArticle() {
let url = this._getOriginalUrl();
this._showProgressDelayed();
let article;
if (this._articlePromise) {
article = yield this._articlePromise;
article = await this._articlePromise;
} else {
try {
article = yield this._getArticle(url);
article = await this._getArticle(url);
} catch (e) {
if (e && e.newURL) {
let readerURL = "about:reader?url=" + encodeURIComponent(e.newURL);
@ -638,24 +670,26 @@ AboutReader.prototype = {
}
this._showContent(article);
}),
_getArticle: function(url) {
return new Promise((resolve, reject) => {
let listener = (message) => {
this._mm.removeMessageListener("Reader:ArticleData", listener);
if (message.data.newURL) {
reject({ newURL: message.data.newURL });
return;
}
resolve(message.data.article);
};
this._mm.addMessageListener("Reader:ArticleData", listener);
this._mm.sendAsyncMessage("Reader:ArticleGet", { url: url });
});
},
_requestFavicon: function() {
_getArticle(url) {
// return new Promise((resolve, reject) => {
// let listener = (message) => {
// this._mm.removeMessageListener("Reader:ArticleData", listener);
// if (message.data.newURL) {
// reject({ newURL: message.data.newURL });
// return;
// }
// resolve(message.data.article);
// };
// this._mm.addMessageListener("Reader:ArticleData", listener);
// this._mm.sendAsyncMessage("Reader:ArticleGet", { url });
// });
//}
return ReaderMode.downloadAndParseDocument(url);
},
_requestFavicon() {
let handleFaviconReturn = (message) => {
this._mm.removeMessageListener("Reader:FaviconReturn", handleFaviconReturn);
this._loadFavicon(message.data.url, message.data.faviconUrl);
@ -665,20 +699,20 @@ AboutReader.prototype = {
this._mm.sendAsyncMessage("Reader:FaviconRequest", { url: this._article.url });
},
_loadFavicon: function(url, faviconUrl) {
_loadFavicon(url, faviconUrl) {
if (this._article.url !== url)
return;
let doc = this._doc;
let link = doc.createElement('link');
link.rel = 'shortcut icon';
let link = doc.createElement("link");
link.rel = "shortcut icon";
link.href = faviconUrl;
doc.getElementsByTagName('head')[0].appendChild(link);
doc.getElementsByTagName("head")[0].appendChild(link);
},
_updateImageMargins: function() {
_updateImageMargins() {
let windowWidth = this._win.innerWidth;
let bodyWidth = this._doc.body.clientWidth;
@ -691,7 +725,7 @@ AboutReader.prototype = {
}
// If the image is at least half as wide as the body, center it on desktop.
if (img.naturalWidth >= bodyWidth/2) {
if (img.naturalWidth >= bodyWidth / 2) {
img.setAttribute("moz-reader-center", true);
} else {
img.removeAttribute("moz-reader-center");
@ -713,30 +747,32 @@ AboutReader.prototype = {
},
_maybeSetTextDirection: function Read_maybeSetTextDirection(article) {
if (!article.dir)
return;
if (article.dir) {
// Set "dir" attribute on content
this._contentElement.setAttribute("dir", article.dir);
this._headerElement.setAttribute("dir", article.dir);
// Set "dir" attribute on content
this._contentElement.setAttribute("dir", article.dir);
this._headerElement.setAttribute("dir", article.dir);
},
_fixLocalLinks() {
// We need to do this because preprocessing the content through nsIParserUtils
// gives back a DOM with a <base> element. That influences how these URLs get
// resolved, making them no longer match the document URI (which is
// about:reader?url=...). To fix this, make all the hash URIs absolute. This
// is hacky, but the alternative of removing the base element has potential
// security implications if Readability has not successfully made all the URLs
// absolute, so we pick just fixing these in-document links explicitly.
let localLinks = this._contentElement.querySelectorAll("a[href^='#']");
for (let localLink of localLinks) {
// Have to get the attribute because .href provides an absolute URI.
localLink.href = this._doc.documentURI + localLink.getAttribute("href");
// The native locale could be set differently than the article's text direction.
var localeDirection = Services.locale.isAppLocaleRTL ? "rtl" : "ltr";
this._readTimeElement.setAttribute("dir", localeDirection);
this._readTimeElement.style.textAlign = article.dir == "rtl" ? "right" : "left";
}
},
_showError: function() {
_formatReadTime(slowEstimate, fastEstimate) {
let displayStringKey = "aboutReader.estimatedReadTimeRange1";
// only show one reading estimate when they are the same value
if (slowEstimate == fastEstimate) {
displayStringKey = "aboutReader.estimatedReadTimeValue1";
}
return PluralForm.get(slowEstimate, gStrings.GetStringFromName(displayStringKey))
.replace("#1", fastEstimate)
.replace("#2", slowEstimate);
},
_showError() {
this._headerElement.style.display = "none";
this._contentElement.style.display = "none";
@ -746,11 +782,16 @@ AboutReader.prototype = {
this._doc.title = errorMessage;
this._doc.documentElement.dataset.isError = true;
this._error = true;
this._doc.dispatchEvent(
new this._win.CustomEvent("AboutReaderContentError", { bubbles: true, cancelable: false }));
},
// This function is the JS version of Java's StringUtils.stripCommonSubdomains.
_stripHost: function(host) {
_stripHost(host) {
if (!host)
return host;
@ -766,17 +807,18 @@ AboutReader.prototype = {
return host.substring(start);
},
_showContent: function(article) {
_showContent(article) {
this._messageElement.style.display = "none";
this._article = article;
this._domainElement.href = article.url;
let articleUri = Services.io.newURI(article.url, null, null);
let articleUri = Services.io.newURI(article.url);
this._domainElement.textContent = this._stripHost(articleUri.host);
this._creditsElement.textContent = article.byline;
this._titleElement.textContent = article.title;
this._readTimeElement.textContent = this._formatReadTime(article.readingTimeMinsSlow, article.readingTimeMinsFast);
this._doc.title = article.title;
this._headerElement.style.display = "block";
@ -787,8 +829,8 @@ AboutReader.prototype = {
false, articleUri, this._contentElement);
this._contentElement.innerHTML = "";
this._contentElement.appendChild(contentFragment);
this._fixLocalLinks();
this._maybeSetTextDirection(article);
this._foundLanguage(article.language);
this._contentElement.style.display = "block";
this._updateImageMargins();
@ -804,13 +846,13 @@ AboutReader.prototype = {
new this._win.CustomEvent("AboutReaderContentReady", { bubbles: true, cancelable: false }));
},
_hideContent: function() {
_hideContent() {
this._headerElement.style.display = "none";
this._contentElement.style.display = "none";
},
_showProgressDelayed: function() {
this._win.setTimeout(function() {
_showProgressDelayed() {
this._win.setTimeout(() => {
// No need to show progress if the article has been loaded,
// if the window has been unloaded, or if there was an error
// trying to load the article.
@ -823,20 +865,20 @@ AboutReader.prototype = {
this._messageElement.textContent = gStrings.GetStringFromName("aboutReader.loading2");
this._messageElement.style.display = "block";
}.bind(this), 300);
}, 300);
},
/**
* Returns the original article URL for this about:reader view.
*/
_getOriginalUrl: function(win) {
_getOriginalUrl(win) {
let url = win ? win.location.href : this._win.location.href;
return ReaderMode.getOriginalUrl(url) || url;
},
_setupSegmentedButton: function(id, options, initialValue, callback) {
_setupSegmentedButton(id, options, initialValue, callback) {
let doc = this._doc;
let segmentedButton = doc.getElementById(id);
let segmentedButton = doc.getElementsByClassName(id)[0];
for (let i = 0; i < options.length; i++) {
let option = options[i];
@ -867,10 +909,6 @@ AboutReader.prototype = {
aEvent.stopPropagation();
// Just pass the ID of the button as an extra and hope the ID doesn't change
// unless the context changes
UITelemetry.addEvent("action.1", "button", null, id);
let items = segmentedButton.children;
for (let j = items.length - 1; j >= 0; j--) {
items[j].classList.remove("selected");
@ -878,19 +916,19 @@ AboutReader.prototype = {
item.classList.add("selected");
callback(option.value);
}.bind(this), true);
}, true);
if (option.value === initialValue)
item.classList.add("selected");
}
},
_setupButton: function(id, callback, titleEntity, textEntity) {
_setupButton(id, callback, titleEntity, textEntity) {
if (titleEntity) {
this._setButtonTip(id, titleEntity);
}
let button = this._doc.getElementById(id);
let button = this._doc.getElementsByClassName(id)[0];
if (textEntity) {
button.textContent = gStrings.GetStringFromName(textEntity);
}
@ -910,17 +948,17 @@ AboutReader.prototype = {
* and dynamically as button state changes.
* @param Localizable string providing UI element usage tip.
*/
_setButtonTip: function(id, titleEntity) {
let button = this._doc.getElementById(id);
_setButtonTip(id, titleEntity) {
let button = this._doc.getElementsByClassName(id)[0];
button.setAttribute("title", gStrings.GetStringFromName(titleEntity));
},
_setupStyleDropdown: function() {
let dropdownToggle = this._doc.querySelector("#style-dropdown .dropdown-toggle");
_setupStyleDropdown() {
let dropdownToggle = this._doc.querySelector(".style-dropdown .dropdown-toggle");
dropdownToggle.setAttribute("title", gStrings.GetStringFromName("aboutReader.toolbar.typeControls"));
},
_updatePopupPosition: function(dropdown) {
_updatePopupPosition(dropdown) {
let dropdownToggle = dropdown.querySelector(".dropdown-toggle");
let dropdownPopup = dropdown.querySelector(".dropdown-popup");
@ -931,8 +969,8 @@ AboutReader.prototype = {
dropdownPopup.style.top = popupTop + "px";
},
_toggleDropdownClicked: function(event) {
let dropdown = event.target.closest('.dropdown');
_toggleDropdownClicked(event) {
let dropdown = event.target.closest(".dropdown");
if (!dropdown)
return;
@ -952,7 +990,7 @@ AboutReader.prototype = {
/*
* If the ReaderView banner font-dropdown is closed, open it.
*/
_openDropdown: function(dropdown) {
_openDropdown(dropdown) {
if (dropdown.classList.contains("open")) {
return;
}
@ -969,7 +1007,7 @@ AboutReader.prototype = {
* dropdowns because the page is scrolling, allow popups to stay open with
* the keep-open class.
*/
_closeDropdowns: function(scrolling) {
_closeDropdowns(scrolling) {
let selector = ".dropdown.open";
if (scrolling) {
selector += ":not(.keep-open)";
@ -986,6 +1024,18 @@ AboutReader.prototype = {
}
},
/*
* Override link handling for same-page references so we don't exit Reader View.
*/
_linkClicked(event) {
var originalUrl = Services.io.newURI(this._getOriginalUrl(), null, null);
var targetUrl = Services.io.newURI(event.target.href, null, null);
if (originalUrl.specIgnoringRef == targetUrl.specIgnoringRef) {
event.preventDefault();
this._goToReference(targetUrl.ref);
}
},
/*
* Scroll reader view to a reference
*/

View file

@ -1017,46 +1017,6 @@
}
},
readScript: function (node) {
while (this.currentChar < this.html.length) {
var c = this.nextChar();
var nextC = this.peekNext();
if (c === "<") {
if (nextC === "!" || nextC === "?") {
// We're still before the ! or ? that is starting this comment:
this.currentChar++;
node.appendChild(this.discardNextComment());
continue;
}
if (nextC === "/" && this.html.substr(this.currentChar, 8 /*"/script>".length */).toLowerCase() == "/script>") {
// Go back before the '<' so we find the end tag.
this.currentChar--;
// Done with this script tag, the caller will close:
return;
}
}
// Either c wasn't a '<' or it was but we couldn't find either a comment
// or a closing script tag, so we should just parse as text until the next one
// comes along:
var haveTextNode = node.lastChild && node.lastChild.nodeType === Node.TEXT_NODE;
var textNode = haveTextNode ? node.lastChild : new Text();
var n = this.html.indexOf("<", this.currentChar);
// Decrement this to include the current character *afterwards* so we don't get stuck
// looking for the same < all the time.
this.currentChar--;
if (n === -1) {
textNode.innerHTML += this.html.substring(this.currentChar, this.html.length);
this.currentChar = this.html.length;
} else {
textNode.innerHTML += this.html.substring(this.currentChar, n);
this.currentChar = n;
}
if (!haveTextNode)
node.appendChild(textNode);
}
},
discardNextComment: function() {
if (this.match("--")) {
this.discardTo("-->");
@ -1131,11 +1091,7 @@
// If this isn't a void Element, read its child nodes
if (!closed) {
if (localName == "script") {
this.readScript(node);
} else {
this.readChildren(node);
}
this.readChildren(node);
var closingTag = "</" + localName + ">";
if (!this.match(closingTag)) {
this.error("expected '" + closingTag + "' and got " + this.html.substr(this.currentChar, closingTag.length));

File diff suppressed because it is too large Load diff

View file

@ -8,15 +8,18 @@ this.EXPORTED_SYMBOLS = ["ReaderMode"];
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
// Constants for telemetry.
const DOWNLOAD_SUCCESS = 0;
const DOWNLOAD_ERROR_XHR = 1;
const DOWNLOAD_ERROR_NO_DOC = 2;
const PARSE_SUCCESS = 0;
const PARSE_ERROR_TOO_MANY_ELEMENTS = 1;
const PARSE_ERROR_WORKER = 2;
const PARSE_ERROR_NO_ARTICLE = 3;
// Class names to preserve in the readerized output. We preserve these class
// names so that rules in aboutReader.css can match them.
const CLASSES_TO_PRESERVE = [
"caption",
"hidden",
"invisble",
"sr-only",
"visually-hidden",
"visuallyhidden",
"wp-caption",
"wp-caption-text",
];
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
@ -24,17 +27,15 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.importGlobalProperties(["XMLHttpRequest"]);
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", "resource://services-common/utils.js");
XPCOMUtils.defineLazyModuleGetter(this, "Messaging", "resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "EventDispatcher", "resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ReaderWorker", "resource://gre/modules/reader/ReaderWorker.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", "resource://gre/modules/TelemetryStopwatch.jsm");
XPCOMUtils.defineLazyGetter(this, "Readability", function() {
let scope = {};
scope.dump = this.dump;
Services.scriptloader.loadSubScript("resource://gre/modules/reader/Readability.js", scope);
return scope["Readability"];
return scope.Readability;
});
this.ReaderMode = {
@ -61,21 +62,13 @@ this.ReaderMode = {
return this.isEnabledForParseOnLoad = this._getStateForParseOnLoad();
},
get isOnLowMemoryPlatform() {
let memory = Cc["@mozilla.org/xpcom/memory-service;1"].getService(Ci.nsIMemory);
delete this.isOnLowMemoryPlatform;
return this.isOnLowMemoryPlatform = memory.isLowMemoryPlatform();
},
_getStateForParseOnLoad: function () {
_getStateForParseOnLoad() {
let isEnabled = Services.prefs.getBoolPref("reader.parse-on-load.enabled");
let isForceEnabled = Services.prefs.getBoolPref("reader.parse-on-load.force-enabled");
// For low-memory devices, don't allow reader mode since it takes up a lot of memory.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=792603 for details.
return isForceEnabled || (isEnabled && !this.isOnLowMemoryPlatform);
return isForceEnabled || isEnabled;
},
observe: function(aMessage, aTopic, aData) {
observe(aMessage, aTopic, aData) {
switch (aTopic) {
case "nsPref:changed":
if (aData.startsWith("reader.parse-on-load.")) {
@ -91,7 +84,7 @@ this.ReaderMode = {
* Enter the reader mode by going forward one step in history if applicable,
* if not, append the about:reader page in the history instead.
*/
enterReaderMode: function(docShell, win) {
enterReaderMode(docShell, win) {
let url = win.document.location.href;
let readerURL = "about:reader?url=" + encodeURIComponent(url);
let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
@ -112,7 +105,7 @@ this.ReaderMode = {
* Exit the reader mode by going back one step in history if applicable,
* if not, append the original page in the history instead.
*/
leaveReaderMode: function(docShell, win) {
leaveReaderMode(docShell, win) {
let url = win.document.location.href;
let originalURL = this.getOriginalUrl(url);
let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
@ -136,14 +129,14 @@ this.ReaderMode = {
* @return The original URL for the article, or null if we did not find
* a properly formatted about:reader URL.
*/
getOriginalUrl: function(url) {
getOriginalUrl(url) {
if (!url.startsWith("about:reader?")) {
return null;
}
let outerHash = "";
try {
let uriObj = Services.io.newURI(url, null, null);
let uriObj = Services.io.newURI(url);
url = uriObj.specIgnoringRef;
outerHash = uriObj.ref;
} catch (ex) { /* ignore, use the raw string */ }
@ -155,27 +148,45 @@ this.ReaderMode = {
let originalUrl = searchParams.get("url");
if (outerHash) {
try {
let uriObj = Services.io.newURI(originalUrl, null, null);
uriObj = Services.io.newURI('#' + outerHash, null, uriObj);
let uriObj = Services.io.newURI(originalUrl);
uriObj = Services.io.newURI("#" + outerHash, null, uriObj);
originalUrl = uriObj.spec;
} catch (ex) {}
}
return originalUrl;
},
getOriginalUrlObjectForDisplay(url) {
let originalUrl = this.getOriginalUrl(url);
if (originalUrl) {
let uriObj;
try {
uriObj = Services.uriFixup.createFixupURI(originalUrl, Services.uriFixup.FIXUP_FLAG_NONE);
} catch (ex) {
return null;
}
try {
return Services.uriFixup.createExposableURI(uriObj);
} catch (ex) {
return null;
}
}
return null;
},
/**
* Decides whether or not a document is reader-able without parsing the whole thing.
*
* @param doc A document to parse.
* @return boolean Whether or not we should show the reader mode button.
*/
isProbablyReaderable: function(doc) {
isProbablyReaderable(doc) {
// Only care about 'real' HTML documents:
if (doc.mozSyntheticDocument || !(doc instanceof doc.defaultView.HTMLDocument)) {
return false;
}
let uri = Services.io.newURI(doc.location.href, null, null);
let uri = Services.io.newURI(doc.location.href);
if (!this._shouldCheckUri(uri)) {
return false;
}
@ -187,12 +198,12 @@ this.ReaderMode = {
return new Readability(uri, doc).isProbablyReaderable(this.isNodeVisible.bind(this, utils));
},
isNodeVisible: function(utils, node) {
isNodeVisible(utils, node) {
let bounds = utils.getBoundsWithoutFlushing(node);
return bounds.height > 0 && bounds.width > 0;
},
getUtilsForWin: function(win) {
getUtilsForWin(win) {
return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
},
@ -204,16 +215,14 @@ this.ReaderMode = {
* @return {Promise}
* @resolves JS object representing the article, or null if no article is found.
*/
parseDocument: Task.async(function* (doc) {
let documentURI = Services.io.newURI(doc.documentURI, null, null);
let baseURI = Services.io.newURI(doc.baseURI, null, null);
if (!this._shouldCheckUri(documentURI) || !this._shouldCheckUri(baseURI, true)) {
parseDocument(doc) {
if (!this._shouldCheckUri(doc.documentURIObject) || !this._shouldCheckUri(doc.baseURIObject, true)) {
this.log("Reader mode disabled for URI");
return null;
}
return yield this._readerParse(baseURI, doc);
}),
return this._readerParse(doc);
},
/**
* Downloads and parses a document from a URL.
@ -222,19 +231,28 @@ this.ReaderMode = {
* @return {Promise}
* @resolves JS object representing the article, or null if no article is found.
*/
downloadAndParseDocument: Task.async(function* (url) {
let doc = yield this._downloadDocument(url);
let uri = Services.io.newURI(doc.baseURI, null, null);
if (!this._shouldCheckUri(uri, true)) {
async downloadAndParseDocument(url) {
let doc = await this._downloadDocument(url);
if (!doc) {
return null;
}
if (!this._shouldCheckUri(doc.documentURIObject) || !this._shouldCheckUri(doc.baseURIObject, true)) {
this.log("Reader mode disabled for URI");
return null;
}
return yield this._readerParse(uri, doc);
}),
return await this._readerParse(doc);
},
_downloadDocument: function (url) {
let histogram = Services.telemetry.getHistogramById("READER_MODE_DOWNLOAD_RESULT");
_downloadDocument(url) {
try {
if (!this._shouldCheckUri(Services.io.newURI(url))) {
return null;
}
} catch (ex) {
Cu.reportError(new Error(`Couldn't create URI from ${url} to download: ${ex}`));
return null;
}
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
@ -243,14 +261,12 @@ this.ReaderMode = {
xhr.onload = evt => {
if (xhr.status !== 200) {
reject("Reader mode XHR failed with status: " + xhr.status);
histogram.add(DOWNLOAD_ERROR_XHR);
return;
}
let doc = xhr.responseXML;
if (!doc) {
reject("Reader mode XHR didn't return a document");
histogram.add(DOWNLOAD_ERROR_NO_DOC);
return;
}
@ -261,7 +277,7 @@ this.ReaderMode = {
if (content) {
let urlIndex = content.toUpperCase().indexOf("URL=");
if (urlIndex > -1) {
let baseURI = Services.io.newURI(url, null, null);
let baseURI = Services.io.newURI(url);
let newURI = Services.io.newURI(content.substring(urlIndex + 4), null, baseURI);
let newURL = newURI.spec;
let ssm = Services.scriptSecurityManager;
@ -290,10 +306,10 @@ this.ReaderMode = {
// Convert these to real URIs to make sure the escaping (or lack
// thereof) is identical:
try {
responseURL = Services.io.newURI(responseURL, null, null).specIgnoringRef;
responseURL = Services.io.newURI(responseURL).specIgnoringRef;
} catch (ex) { /* Ignore errors - we'll use what we had before */ }
try {
givenURL = Services.io.newURI(givenURL, null, null).specIgnoringRef;
givenURL = Services.io.newURI(givenURL).specIgnoringRef;
} catch (ex) { /* Ignore errors - we'll use what we had before */ }
if (responseURL != givenURL) {
@ -303,7 +319,6 @@ this.ReaderMode = {
return;
}
resolve(doc);
histogram.add(DOWNLOAD_SUCCESS);
};
xhr.send();
});
@ -318,17 +333,17 @@ this.ReaderMode = {
* @resolves JS object representing the article, or null if no article is found.
* @rejects OS.File.Error
*/
getArticleFromCache: Task.async(function* (url) {
async getArticleFromCache(url) {
let path = this._toHashedPath(url);
try {
let array = yield OS.File.read(path);
let array = await OS.File.read(path);
return JSON.parse(new TextDecoder().decode(array));
} catch (e) {
if (!(e instanceof OS.File.Error) || !e.becauseNoSuchFile)
throw e;
return null;
}
}),
},
/**
* Stores an article in the cache.
@ -338,14 +353,14 @@ this.ReaderMode = {
* @resolves When the article is stored.
* @rejects OS.File.Error
*/
storeArticleInCache: Task.async(function* (article) {
async storeArticleInCache(article) {
let array = new TextEncoder().encode(JSON.stringify(article));
let path = this._toHashedPath(article.url);
yield this._ensureCacheDir();
await this._ensureCacheDir();
return OS.File.writeAtomic(path, array, { tmpPath: path + ".tmp" })
.then(success => {
OS.File.stat(path).then(info => {
return Messaging.sendRequest({
return EventDispatcher.instance.sendRequest({
type: "Reader:AddedToCache",
url: article.url,
size: info.size,
@ -353,7 +368,7 @@ this.ReaderMode = {
});
});
});
}),
},
/**
* Removes an article from the cache given an article URI.
@ -363,26 +378,29 @@ this.ReaderMode = {
* @resolves When the article is removed.
* @rejects OS.File.Error
*/
removeArticleFromCache: Task.async(function* (url) {
async removeArticleFromCache(url) {
let path = this._toHashedPath(url);
yield OS.File.remove(path);
}),
await OS.File.remove(path);
},
log: function(msg) {
log(msg) {
if (this.DEBUG)
dump("Reader: " + msg);
},
_blockedHosts: [
"mail.google.com",
"amazon.com",
"basilisk-browser.org",
"github.com",
"mail.google.com",
"palemoon.org",
"pinterest.com",
"reddit.com",
"twitter.com",
"youtube.com",
],
_shouldCheckUri: function (uri, isBaseUri = false) {
_shouldCheckUri(uri, isBaseUri = false) {
if (!(uri.schemeIs("http") || uri.schemeIs("https"))) {
this.log("Not parsing URI scheme: " + uri.scheme);
return false;
@ -412,59 +430,77 @@ this.ReaderMode = {
* Attempts to parse a document into an article. Heavy lifting happens
* in readerWorker.js.
*
* @param uri The base URI of the article.
* @param doc The document to parse.
* @return {Promise}
* @resolves JS object representing the article, or null if no article is found.
*/
_readerParse: Task.async(function* (uri, doc) {
let histogram = Services.telemetry.getHistogramById("READER_MODE_PARSE_RESULT");
async _readerParse(doc) {
if (this.parseNodeLimit) {
let numTags = doc.getElementsByTagName("*").length;
if (numTags > this.parseNodeLimit) {
this.log("Aborting parse for " + uri.spec + "; " + numTags + " elements found");
histogram.add(PARSE_ERROR_TOO_MANY_ELEMENTS);
this.log("Aborting parse for " + doc.baseURIObject.spec + "; " + numTags + " elements found");
return null;
}
}
// Fetch this here before we send `doc` off to the worker thread, as later on the
// document might be nuked but we will still want the URI.
let {documentURI} = doc;
let uriParam = {
spec: uri.spec,
host: uri.host,
prePath: uri.prePath,
scheme: uri.scheme,
pathBase: Services.io.newURI(".", null, uri).spec
spec: doc.baseURIObject.spec,
host: doc.baseURIObject.host,
prePath: doc.baseURIObject.prePath,
scheme: doc.baseURIObject.scheme,
pathBase: Services.io.newURI(".", null, doc.baseURIObject).spec
};
let langAttributes = {
charset: doc.characterSet,
lang: doc.documentElement.lang
};
let serializer = Cc["@mozilla.org/xmlextras/xmlserializer;1"].
createInstance(Ci.nsIDOMSerializer);
let serializedDoc = serializer.serializeToString(doc);
let options = {
classesToPreserve: CLASSES_TO_PRESERVE,
};
let article = null;
try {
article = yield ReaderWorker.post("parseDocument", [uriParam, serializedDoc]);
article = await ReaderWorker.post("parseDocument", [uriParam, serializedDoc, options]);
} catch (e) {
Cu.reportError("Error in ReaderWorker: " + e);
histogram.add(PARSE_ERROR_WORKER);
}
// Explicitly null out doc to make it clear it might not be available from this
// point on.
doc = null;
if (!article) {
this.log("Worker did not return an article");
histogram.add(PARSE_ERROR_NO_ARTICLE);
return null;
}
// Readability returns a URI object, but we only care about the URL.
article.url = article.uri.spec;
// Readability returns a URI object based on the baseURI, but we only care
// about the original document's URL from now on. This also avoids spoofing
// attempts where the baseURI doesn't match the domain of the documentURI
article.url = documentURI;
delete article.uri;
let flags = Ci.nsIDocumentEncoder.OutputSelectionOnly | Ci.nsIDocumentEncoder.OutputAbsoluteLinks;
article.title = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils)
.convertToPlainText(article.title, flags, 0);
histogram.add(PARSE_SUCCESS);
await this._assignLanguage(article, langAttributes);
this._maybeAssignTextDirection(article);
this._assignReadTime(article);
return article;
}),
},
get _cryptoHash() {
delete this._cryptoHash;
@ -485,7 +521,7 @@ this.ReaderMode = {
* @param url The article URL. This should have referrers removed.
* @return The file path to the cached article.
*/
_toHashedPath: function (url) {
_toHashedPath(url) {
let value = this._unicodeConverter.convertToByteArray(url);
this._cryptoHash.init(this._cryptoHash.MD5);
this._cryptoHash.update(value, value.length);
@ -502,7 +538,7 @@ this.ReaderMode = {
* @resolves When the cache directory exists.
* @rejects OS.File.Error
*/
_ensureCacheDir: function () {
_ensureCacheDir() {
let dir = OS.Path.join(OS.Constants.Path.profileDir, "readercache");
return OS.File.exists(dir).then(exists => {
if (!exists) {
@ -510,5 +546,107 @@ this.ReaderMode = {
}
return undefined;
});
}
},
/**
* Sets a global language string value if possible. If langauge detection is
* available, use that. Otherwise, revert to a simpler mechanism using the
* document's lang attribute or charset.
*
* @return Promise
* @resolves when the language is detected
*/
_assignLanguage(article, attributes) {
try {
Cu.import("resource://modules/translation/LanguageDetector.jsm");
return LanguageDetector.detectLanguage(article.textContent).then(result => {
article.language = result.confident ? result.language : null;
});
} catch(ex) {
return new Promise((resolve) => {
resolve(this._assignSimpleLanguage(attributes));
}).then(result => {
article.language = result;
});
}
},
_assignSimpleLanguage(attributes) {
var lang = attributes.lang.substring(0,2);
if (lang) {
return lang;
}
// If there is no lang attribute, try the charset.
// We can only use this for charsets that are specific to one language.
const charsetLang = new Map([
[ "us-ascii", "en" ],
[ "iso-8859-6", "ar" ],
[ "iso-8859-7", "el" ],
[ "iso-8859-8", "he" ],
[ "iso-8859-9", "tr" ],
[ "iso-8859-11", "th" ],
[ "jis_x0201", "ja" ],
[ "shift_jis", "ja" ],
[ "euc-jp", "ja" ]
]);
return charsetLang.get(attributes.charset);
},
_maybeAssignTextDirection(article) {
// TODO: Remove the hardcoded language codes below once bug 1320265 is resolved.
if (!article.dir && ["ar", "fa", "he", "ug", "ur"].includes(article.language)) {
article.dir = "rtl";
}
},
/**
* Assigns the estimated reading time range of the article to the article object.
*
* @param article the article object to assign the reading time estimate to.
*/
_assignReadTime(article) {
let lang = article.language || "en";
const readingSpeed = this._getReadingSpeedForLanguage(lang);
const charactersPerMinuteLow = readingSpeed.cpm - readingSpeed.variance;
const charactersPerMinuteHigh = readingSpeed.cpm + readingSpeed.variance;
const length = article.length;
article.readingTimeMinsSlow = Math.ceil(length / charactersPerMinuteLow);
article.readingTimeMinsFast = Math.ceil(length / charactersPerMinuteHigh);
},
/**
* Returns the reading speed of a selection of languages with likely variance.
*
* Reading speed estimated from a study done on reading speeds in various languages.
* study can be found here: http://iovs.arvojournals.org/article.aspx?articleid=2166061
*
* @return object with characters per minute and variance. Defaults to English
* if no suitable language is found in the collection.
*/
_getReadingSpeedForLanguage(lang) {
const readingSpeed = new Map([
[ "en", {cpm: 987, variance: 118 } ],
[ "ar", {cpm: 612, variance: 88 } ],
[ "de", {cpm: 920, variance: 86 } ],
[ "es", {cpm: 1025, variance: 127 } ],
[ "fi", {cpm: 1078, variance: 121 } ],
[ "fr", {cpm: 998, variance: 126 } ],
[ "he", {cpm: 833, variance: 130 } ],
[ "it", {cpm: 950, variance: 140 } ],
[ "jw", {cpm: 357, variance: 56 } ],
[ "nl", {cpm: 978, variance: 143 } ],
[ "pl", {cpm: 916, variance: 126 } ],
[ "pt", {cpm: 913, variance: 145 } ],
[ "ru", {cpm: 986, variance: 175 } ],
[ "sk", {cpm: 885, variance: 145 } ],
[ "sv", {cpm: 917, variance: 156 } ],
[ "tr", {cpm: 1054, variance: 156 } ],
[ "zh", {cpm: 255, variance: 29 } ],
]);
return readingSpeed.get(lang) || readingSpeed.get("en");
},
};

View file

@ -2,6 +2,8 @@
* 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/. */
/* eslint-env mozilla/chrome-worker */
"use strict";
/**
@ -40,11 +42,12 @@ var Agent = {
*
* @param {object} uri URI data for the document.
* @param {string} serializedDoc The serialized document.
* @param {object} options Options object to pass to Readability.
*
* @return {object} Article object returned from Readability.
*/
parseDocument: function (uri, serializedDoc) {
parseDocument(uri, serializedDoc, options) {
let doc = new JSDOMParser().parse(serializedDoc);
return new Readability(uri, doc).parse();
return new Readability(uri, doc, options).parse();
},
};

View file

@ -7,63 +7,56 @@
<link rel="stylesheet" href="chrome://global/skin/aboutReader.css" type="text/css"/>
<script type="text/javascript;version=1.8" src="chrome://global/content/reader/aboutReader.js"></script>
<script type="text/javascript" src="chrome://global/content/reader/aboutReader.js"></script>
</head>
<body>
<div id="container" class="container">
<div id="reader-header" class="header">
<style scoped>
@import url("chrome://global/skin/aboutReaderControls.css");
</style>
<a id="reader-domain" class="domain"></a>
<div class="container">
<div class="header reader-header">
<a class="domain reader-domain"></a>
<div class="domain-border"></div>
<h1 id="reader-title"></h1>
<div id="reader-credits" class="credits"></div>
<h1 class="reader-title"></h1>
<div class="credits reader-credits"></div>
<div class="meta-data">
<div class="reader-estimated-time"></div>
</div>
</div>
<hr>
<div class="content">
<style scoped>
@import url("chrome://global/skin/aboutReaderContent.css");
</style>
<div id="moz-reader-content"></div>
<div class="moz-reader-content"></div>
</div>
<div>
<style scoped>
@import url("chrome://global/skin/aboutReaderControls.css");
</style>
<div id="reader-message"></div>
<div class="reader-message"></div>
</div>
</div>
<ul id="reader-toolbar" class="toolbar">
<style scoped>
@import url("chrome://global/skin/aboutReaderControls.css");
</style>
<li><button id="close-button" class="button close-button"/></li>
<ul id="style-dropdown" class="dropdown">
<ul class="toolbar reader-toolbar">
<li><button class="button close-button"/></li>
<ul class="dropdown style-dropdown">
<li><button class="dropdown-toggle button style-button"/></li>
<li id="reader-popup" class="dropdown-popup">
<div id="font-type-buttons"></div>
<hr></hr>
<div id="font-size-buttons">
<button id="font-size-minus" class="minus-button"/>
<button id="font-size-sample"/>
<button id="font-size-plus" class="plus-button"/>
<li class="dropdown-popup">
<div class="font-type-buttons"></div>
<hr>
<div class="font-size-buttons">
<button class="minus-button"/>
<button class="font-size-sample"/>
<button class="plus-button"/>
</div>
<hr></hr>
<div id="content-width-buttons">
<button id="content-width-minus" class="content-width-minus-button"/>
<button id="content-width-plus" class="content-width-plus-button"/>
<hr>
<div class="content-width-buttons">
<button class="content-width-minus-button"/>
<button class="content-width-plus-button"/>
</div>
<hr></hr>
<div id="line-height-buttons">
<button id="line-height-minus" class="line-height-minus-button"/>
<button id="line-height-plus" class="line-height-plus-button"/>
<hr>
<div class="line-height-buttons">
<button class="line-height-minus-button"/>
<button class="line-height-plus-button"/>
</div>
<hr></hr>
<div id="color-scheme-buttons"></div>
<hr>
<div class="color-scheme-buttons"></div>
<div class="dropdown-arrow"/>
</li>
</ul>

View file

@ -4,6 +4,6 @@
"use strict";
window.addEventListener("DOMContentLoaded", function () {
window.addEventListener("DOMContentLoaded", function() {
document.dispatchEvent(new CustomEvent("AboutReaderContentLoaded", { bubbles: true }));
});