Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
* You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
"use strict";
6
7
var EXPORTED_SYMBOLS = ["AboutReader"];
8
9
const { AppConstants } = ChromeUtils.import(
11
);
12
const { ReaderMode } = ChromeUtils.import(
14
);
15
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
16
17
ChromeUtils.defineModuleGetter(
18
this,
19
"AsyncPrefs",
21
);
22
ChromeUtils.defineModuleGetter(
23
this,
24
"NarrateControls",
26
);
27
ChromeUtils.defineModuleGetter(
28
this,
29
"UITelemetry",
31
);
32
ChromeUtils.defineModuleGetter(
33
this,
34
"PluralForm",
36
);
37
38
var gStrings = Services.strings.createBundle(
40
);
41
42
const zoomOnCtrl =
43
Services.prefs.getIntPref("mousewheel.with_control.action", 3) == 3;
44
const zoomOnMeta =
45
Services.prefs.getIntPref("mousewheel.with_meta.action", 1) == 3;
46
47
const gIsFirefoxDesktop =
48
Services.appinfo.ID == "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
49
50
var AboutReader = function(mm, win, articlePromise) {
51
let url = this._getOriginalUrl(win);
52
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
53
let errorMsg =
54
"Only http:// and https:// URLs can be loaded in about:reader.";
55
if (Services.prefs.getBoolPref("reader.errors.includeURLs")) {
56
errorMsg += " Tried to load: " + url + ".";
57
}
58
Cu.reportError(errorMsg);
59
win.location.href = "about:blank";
60
return;
61
}
62
63
let doc = win.document;
64
65
this._mm = mm;
66
this._mm.addMessageListener("Reader:CloseDropdown", this);
67
this._mm.addMessageListener("Reader:AddButton", this);
68
this._mm.addMessageListener("Reader:RemoveButton", this);
69
this._mm.addMessageListener("Reader:GetStoredArticleData", this);
70
this._mm.addMessageListener("Reader:ZoomIn", this);
71
this._mm.addMessageListener("Reader:ZoomOut", this);
72
this._mm.addMessageListener("Reader:ResetZoom", this);
73
74
this._docRef = Cu.getWeakReference(doc);
75
this._winRef = Cu.getWeakReference(win);
76
this._innerWindowId = win.windowUtils.currentInnerWindowID;
77
78
this._article = null;
79
this._languagePromise = new Promise(resolve => {
80
this._foundLanguage = resolve;
81
});
82
83
if (articlePromise) {
84
this._articlePromise = articlePromise;
85
}
86
87
this._headerElementRef = Cu.getWeakReference(
88
doc.querySelector(".reader-header")
89
);
90
this._domainElementRef = Cu.getWeakReference(
91
doc.querySelector(".reader-domain")
92
);
93
this._titleElementRef = Cu.getWeakReference(
94
doc.querySelector(".reader-title")
95
);
96
this._readTimeElementRef = Cu.getWeakReference(
97
doc.querySelector(".reader-estimated-time")
98
);
99
this._creditsElementRef = Cu.getWeakReference(
100
doc.querySelector(".reader-credits")
101
);
102
this._contentElementRef = Cu.getWeakReference(
103
doc.querySelector(".moz-reader-content")
104
);
105
this._toolbarElementRef = Cu.getWeakReference(
106
doc.querySelector(".reader-toolbar")
107
);
108
this._messageElementRef = Cu.getWeakReference(
109
doc.querySelector(".reader-message")
110
);
111
this._containerElementRef = Cu.getWeakReference(
112
doc.querySelector(".container")
113
);
114
115
this._scrollOffset = win.pageYOffset;
116
117
doc.addEventListener("mousedown", this);
118
doc.addEventListener("click", this);
119
doc.addEventListener("touchstart", this);
120
121
win.addEventListener("pagehide", this);
122
win.addEventListener("mozvisualscroll", this, { mozSystemGroup: true });
123
win.addEventListener("resize", this);
124
win.addEventListener("wheel", this, { passive: false });
125
126
Services.obs.addObserver(this, "inner-window-destroyed");
127
128
doc.addEventListener("visibilitychange", this);
129
130
this._setupStyleDropdown();
131
this._setupButton(
132
"close-button",
133
this._onReaderClose.bind(this),
134
"aboutReader.toolbar.close"
135
);
136
137
if (gIsFirefoxDesktop) {
138
// we're ready for any external setup, send a signal for that.
139
this._mm.sendAsyncMessage("Reader:OnSetup");
140
}
141
142
let colorSchemeValues = JSON.parse(
143
Services.prefs.getCharPref("reader.color_scheme.values")
144
);
145
let colorSchemeOptions = colorSchemeValues.map(value => {
146
return {
147
name: gStrings.GetStringFromName("aboutReader.colorScheme." + value),
148
value,
149
itemClass: value + "-button",
150
};
151
});
152
153
let colorScheme = Services.prefs.getCharPref("reader.color_scheme");
154
this._setupSegmentedButton(
155
"color-scheme-buttons",
156
colorSchemeOptions,
157
colorScheme,
158
this._setColorSchemePref.bind(this)
159
);
160
this._setColorSchemePref(colorScheme);
161
162
let fontTypeSample = gStrings.GetStringFromName("aboutReader.fontTypeSample");
163
let fontTypeOptions = [
164
{
165
name: fontTypeSample,
166
description: gStrings.GetStringFromName(
167
"aboutReader.fontType.sans-serif"
168
),
169
value: "sans-serif",
170
itemClass: "sans-serif-button",
171
},
172
{
173
name: fontTypeSample,
174
description: gStrings.GetStringFromName("aboutReader.fontType.serif"),
175
value: "serif",
176
itemClass: "serif-button",
177
},
178
];
179
180
let fontType = Services.prefs.getCharPref("reader.font_type");
181
this._setupSegmentedButton(
182
"font-type-buttons",
183
fontTypeOptions,
184
fontType,
185
this._setFontType.bind(this)
186
);
187
this._setFontType(fontType);
188
189
this._setupFontSizeButtons();
190
191
this._setupContentWidthButtons();
192
193
this._setupLineHeightButtons();
194
195
if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
196
new NarrateControls(mm, win, this._languagePromise);
197
}
198
199
this._loadArticle();
200
201
let dropdown = this._toolbarElement;
202
203
let elemL10nMap = {
204
".minus-button": "minus",
205
".plus-button": "plus",
206
".content-width-minus-button": "contentwidthminus",
207
".content-width-plus-button": "contentwidthplus",
208
".line-height-minus-button": "lineheightminus",
209
".line-height-plus-button": "lineheightplus",
210
".light-button": "colorschemelight",
211
".dark-button": "colorschemedark",
212
".sepia-button": "colorschemesepia",
213
};
214
215
for (let [selector, stringID] of Object.entries(elemL10nMap)) {
216
dropdown
217
.querySelector(selector)
218
.setAttribute(
219
"title",
220
gStrings.GetStringFromName("aboutReader.toolbar." + stringID)
221
);
222
}
223
};
224
225
AboutReader.prototype = {
226
_BLOCK_IMAGES_SELECTOR:
227
".content p > img:only-child, " +
228
".content p > a:only-child > img:only-child, " +
229
".content .wp-caption img, " +
230
".content figure img",
231
232
PLATFORM_HAS_CACHE: AppConstants.platform == "android",
233
234
FONT_SIZE_MIN: 1,
235
236
FONT_SIZE_LEGACY_MAX: 9,
237
238
FONT_SIZE_MAX: 15,
239
240
FONT_SIZE_EXTENDED_VALUES: [32, 40, 56, 72, 96, 128],
241
242
get _doc() {
243
return this._docRef.get();
244
},
245
246
get _win() {
247
return this._winRef.get();
248
},
249
250
get _headerElement() {
251
return this._headerElementRef.get();
252
},
253
254
get _domainElement() {
255
return this._domainElementRef.get();
256
},
257
258
get _titleElement() {
259
return this._titleElementRef.get();
260
},
261
262
get _readTimeElement() {
263
return this._readTimeElementRef.get();
264
},
265
266
get _creditsElement() {
267
return this._creditsElementRef.get();
268
},
269
270
get _contentElement() {
271
return this._contentElementRef.get();
272
},
273
274
get _toolbarElement() {
275
return this._toolbarElementRef.get();
276
},
277
278
get _messageElement() {
279
return this._messageElementRef.get();
280
},
281
282
get _containerElement() {
283
return this._containerElementRef.get();
284
},
285
286
get _isToolbarVertical() {
287
if (this._toolbarVertical !== undefined) {
288
return this._toolbarVertical;
289
}
290
return (this._toolbarVertical = Services.prefs.getBoolPref(
291
"reader.toolbar.vertical"
292
));
293
},
294
295
// Provides unique view Id.
296
get viewId() {
297
let _viewId = Cc["@mozilla.org/uuid-generator;1"]
298
.getService(Ci.nsIUUIDGenerator)
299
.generateUUID()
300
.toString();
301
Object.defineProperty(this, "viewId", { value: _viewId });
302
303
return _viewId;
304
},
305
306
receiveMessage(message) {
307
switch (message.name) {
308
// Triggered by Android user pressing BACK while the banner font-dropdown is open.
309
case "Reader:CloseDropdown": {
310
// Just close it.
311
this._closeDropdowns();
312
break;
313
}
314
315
case "Reader:AddButton": {
316
if (
317
message.data.id &&
318
message.data.image &&
319
!this._doc.getElementsByClassName(message.data.id)[0]
320
) {
321
let btn = this._doc.createElement("button");
322
btn.dataset.buttonid = message.data.id;
323
btn.className = "button " + message.data.id;
324
btn.style.backgroundImage = "url('" + message.data.image + "')";
325
if (message.data.title) {
326
btn.title = message.data.title;
327
}
328
if (message.data.text) {
329
btn.textContent = message.data.text;
330
}
331
if (message.data.width && message.data.height) {
332
btn.style.backgroundSize = `${message.data.width}px ${
333
message.data.height
334
}px`;
335
}
336
let tb = this._toolbarElement;
337
tb.appendChild(btn);
338
this._setupButton(message.data.id, button => {
339
this._mm.sendAsyncMessage(
340
"Reader:Clicked-" + button.dataset.buttonid,
341
{ article: this._article }
342
);
343
});
344
}
345
break;
346
}
347
case "Reader:RemoveButton": {
348
if (message.data.id) {
349
let btn = this._doc.getElementsByClassName(message.data.id)[0];
350
if (btn) {
351
btn.remove();
352
}
353
}
354
break;
355
}
356
case "Reader:GetStoredArticleData": {
357
this._mm.sendAsyncMessage("Reader:StoredArticleData", {
358
article: this._article,
359
});
360
break;
361
}
362
case "Reader:ZoomIn": {
363
this._changeFontSize(+1);
364
break;
365
}
366
case "Reader:ZoomOut": {
367
this._changeFontSize(-1);
368
break;
369
}
370
case "Reader:ResetZoom": {
371
this._resetFontSize();
372
break;
373
}
374
}
375
},
376
377
handleEvent(aEvent) {
378
if (!aEvent.isTrusted) {
379
return;
380
}
381
382
let target = aEvent.target;
383
switch (aEvent.type) {
384
case "touchstart":
385
/* fall through */
386
case "mousedown":
387
if (!target.closest(".dropdown-popup")) {
388
this._closeDropdowns();
389
}
390
break;
391
case "click":
392
if (target.classList.contains("dropdown-toggle")) {
393
this._toggleDropdownClicked(aEvent);
394
}
395
break;
396
case "mozvisualscroll":
397
const vv = aEvent.originalTarget; // VisualViewport
398
399
if (gIsFirefoxDesktop) {
400
this._closeDropdowns(true);
401
} else if (this._scrollOffset != vv.pageTop) {
402
// hide the system UI and the "reader-toolbar" only if the dropdown is not opened
403
let selector = ".dropdown.open";
404
let openDropdowns = this._doc.querySelectorAll(selector);
405
if (openDropdowns.length) {
406
break;
407
}
408
409
let isScrollingUp = this._scrollOffset > vv.pageTop;
410
this._setSystemUIVisibility(isScrollingUp);
411
this._setToolbarVisibility(isScrollingUp);
412
}
413
414
this._scrollOffset = vv.pageTop;
415
break;
416
case "resize":
417
this._updateImageMargins();
418
if (this._isToolbarVertical) {
419
this._win.setTimeout(() => {
420
for (let dropdown of this._doc.querySelectorAll(".dropdown.open")) {
421
this._updatePopupPosition(dropdown);
422
}
423
}, 0);
424
}
425
break;
426
427
case "wheel":
428
let doZoom =
429
(aEvent.ctrlKey && zoomOnCtrl) || (aEvent.metaKey && zoomOnMeta);
430
if (!doZoom) {
431
return;
432
}
433
aEvent.preventDefault();
434
435
// Throttle events to once per 150ms. This avoids excessively fast zooming.
436
if (aEvent.timeStamp <= this._zoomBackoffTime) {
437
return;
438
}
439
this._zoomBackoffTime = aEvent.timeStamp + 150;
440
441
// Determine the direction of the delta (we don't care about its size);
442
// This code is adapted from normalizeWheelEventDelta in
443
// browser/extensions/pdfjs/content/web/viewer.js
444
let delta = Math.abs(aEvent.deltaX) + Math.abs(aEvent.deltaY);
445
let angle = Math.atan2(aEvent.deltaY, aEvent.deltaX);
446
if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) {
447
delta = -delta;
448
}
449
450
if (delta > 0) {
451
this._changeFontSize(+1);
452
} else if (delta < 0) {
453
this._changeFontSize(-1);
454
}
455
break;
456
457
case "devicelight":
458
this._handleDeviceLight(aEvent.value);
459
break;
460
461
case "visibilitychange":
462
this._handleVisibilityChange();
463
break;
464
465
case "pagehide":
466
// Close the Banners Font-dropdown, cleanup Android BackPressListener.
467
this._closeDropdowns();
468
469
this._mm.removeMessageListener("Reader:CloseDropdown", this);
470
this._mm.removeMessageListener("Reader:AddButton", this);
471
this._mm.removeMessageListener("Reader:RemoveButton", this);
472
this._mm.removeMessageListener("Reader:GetStoredArticleData", this);
473
this._mm.removeMessageListener("Reader:ZoomIn", this);
474
this._mm.removeMessageListener("Reader:ZoomOut", this);
475
this._mm.removeMessageListener("Reader:ResetZoom", this);
476
this._windowUnloaded = true;
477
break;
478
}
479
},
480
481
observe(subject, topic, data) {
482
if (
483
subject.QueryInterface(Ci.nsISupportsPRUint64).data != this._innerWindowId
484
) {
485
return;
486
}
487
488
Services.obs.removeObserver(this, "inner-window-destroyed");
489
490
this._mm.removeMessageListener("Reader:CloseDropdown", this);
491
this._mm.removeMessageListener("Reader:AddButton", this);
492
this._mm.removeMessageListener("Reader:RemoveButton", this);
493
this._windowUnloaded = true;
494
},
495
496
_onReaderClose() {
497
ReaderMode.leaveReaderMode(this._mm.docShell, this._win);
498
},
499
500
async _resetFontSize() {
501
await AsyncPrefs.reset("reader.font_size");
502
let currentSize = Services.prefs.getIntPref("reader.font_size");
503
this._setFontSize(currentSize);
504
},
505
506
_setFontSize(newFontSize) {
507
this._fontSize = Math.min(
508
this.FONT_SIZE_MAX,
509
Math.max(this.FONT_SIZE_MIN, newFontSize)
510
);
511
let size;
512
if (this._fontSize > this.FONT_SIZE_LEGACY_MAX) {
513
// -1 because we're indexing into a 0-indexed array, so the first value
514
// over the legacy max should be 0, the next 1, etc.
515
let index = this._fontSize - this.FONT_SIZE_LEGACY_MAX - 1;
516
size = this.FONT_SIZE_EXTENDED_VALUES[index];
517
} else {
518
size = 10 + 2 * this._fontSize;
519
}
520
521
this._containerElement.style.setProperty("--font-size", size + "px");
522
return AsyncPrefs.set("reader.font_size", this._fontSize);
523
},
524
525
_setupFontSizeButtons() {
526
// Sample text shown in Android UI.
527
let sampleText = this._doc.querySelector(".font-size-sample");
528
sampleText.textContent = gStrings.GetStringFromName(
529
"aboutReader.fontTypeSample"
530
);
531
532
let plusButton = this._doc.querySelector(".plus-button");
533
let minusButton = this._doc.querySelector(".minus-button");
534
535
let currentSize = Services.prefs.getIntPref("reader.font_size");
536
this._setFontSize(currentSize);
537
this._updateFontSizeButtonControls();
538
539
plusButton.addEventListener(
540
"click",
541
event => {
542
if (!event.isTrusted) {
543
return;
544
}
545
event.stopPropagation();
546
this._changeFontSize(+1);
547
},
548
true
549
);
550
551
minusButton.addEventListener(
552
"click",
553
event => {
554
if (!event.isTrusted) {
555
return;
556
}
557
event.stopPropagation();
558
this._changeFontSize(-1);
559
},
560
true
561
);
562
},
563
564
_updateFontSizeButtonControls() {
565
let plusButton = this._doc.querySelector(".plus-button");
566
let minusButton = this._doc.querySelector(".minus-button");
567
568
let currentSize = Services.prefs.getIntPref("reader.font_size");
569
570
if (currentSize === this.FONT_SIZE_MIN) {
571
minusButton.setAttribute("disabled", true);
572
} else {
573
minusButton.removeAttribute("disabled");
574
}
575
if (currentSize === this.FONT_SIZE_MAX) {
576
plusButton.setAttribute("disabled", true);
577
} else {
578
plusButton.removeAttribute("disabled");
579
}
580
},
581
582
_changeFontSize(changeAmount) {
583
let currentSize =
584
Services.prefs.getIntPref("reader.font_size") + changeAmount;
585
this._setFontSize(currentSize);
586
this._updateFontSizeButtonControls();
587
},
588
589
_setContentWidth(newContentWidth) {
590
let containerClasses = this._containerElement.classList;
591
592
if (this._contentWidth > 0) {
593
containerClasses.remove("content-width" + this._contentWidth);
594
}
595
596
this._contentWidth = newContentWidth;
597
containerClasses.add("content-width" + this._contentWidth);
598
return AsyncPrefs.set("reader.content_width", this._contentWidth);
599
},
600
601
_setupContentWidthButtons() {
602
const CONTENT_WIDTH_MIN = 1;
603
const CONTENT_WIDTH_MAX = 9;
604
605
let currentContentWidth = Services.prefs.getIntPref("reader.content_width");
606
currentContentWidth = Math.max(
607
CONTENT_WIDTH_MIN,
608
Math.min(CONTENT_WIDTH_MAX, currentContentWidth)
609
);
610
611
let plusButton = this._doc.querySelector(".content-width-plus-button");
612
let minusButton = this._doc.querySelector(".content-width-minus-button");
613
614
function updateControls() {
615
if (currentContentWidth === CONTENT_WIDTH_MIN) {
616
minusButton.setAttribute("disabled", true);
617
} else {
618
minusButton.removeAttribute("disabled");
619
}
620
if (currentContentWidth === CONTENT_WIDTH_MAX) {
621
plusButton.setAttribute("disabled", true);
622
} else {
623
plusButton.removeAttribute("disabled");
624
}
625
}
626
627
updateControls();
628
this._setContentWidth(currentContentWidth);
629
630
plusButton.addEventListener(
631
"click",
632
event => {
633
if (!event.isTrusted) {
634
return;
635
}
636
event.stopPropagation();
637
638
if (currentContentWidth >= CONTENT_WIDTH_MAX) {
639
return;
640
}
641
642
currentContentWidth++;
643
updateControls();
644
this._setContentWidth(currentContentWidth);
645
},
646
true
647
);
648
649
minusButton.addEventListener(
650
"click",
651
event => {
652
if (!event.isTrusted) {
653
return;
654
}
655
event.stopPropagation();
656
657
if (currentContentWidth <= CONTENT_WIDTH_MIN) {
658
return;
659
}
660
661
currentContentWidth--;
662
updateControls();
663
this._setContentWidth(currentContentWidth);
664
},
665
true
666
);
667
},
668
669
_setLineHeight(newLineHeight) {
670
let contentClasses = this._contentElement.classList;
671
672
if (this._lineHeight > 0) {
673
contentClasses.remove("line-height" + this._lineHeight);
674
}
675
676
this._lineHeight = newLineHeight;
677
contentClasses.add("line-height" + this._lineHeight);
678
return AsyncPrefs.set("reader.line_height", this._lineHeight);
679
},
680
681
_setupLineHeightButtons() {
682
const LINE_HEIGHT_MIN = 1;
683
const LINE_HEIGHT_MAX = 9;
684
685
let currentLineHeight = Services.prefs.getIntPref("reader.line_height");
686
currentLineHeight = Math.max(
687
LINE_HEIGHT_MIN,
688
Math.min(LINE_HEIGHT_MAX, currentLineHeight)
689
);
690
691
let plusButton = this._doc.querySelector(".line-height-plus-button");
692
let minusButton = this._doc.querySelector(".line-height-minus-button");
693
694
function updateControls() {
695
if (currentLineHeight === LINE_HEIGHT_MIN) {
696
minusButton.setAttribute("disabled", true);
697
} else {
698
minusButton.removeAttribute("disabled");
699
}
700
if (currentLineHeight === LINE_HEIGHT_MAX) {
701
plusButton.setAttribute("disabled", true);
702
} else {
703
plusButton.removeAttribute("disabled");
704
}
705
}
706
707
updateControls();
708
this._setLineHeight(currentLineHeight);
709
710
plusButton.addEventListener(
711
"click",
712
event => {
713
if (!event.isTrusted) {
714
return;
715
}
716
event.stopPropagation();
717
718
if (currentLineHeight >= LINE_HEIGHT_MAX) {
719
return;
720
}
721
722
currentLineHeight++;
723
updateControls();
724
this._setLineHeight(currentLineHeight);
725
},
726
true
727
);
728
729
minusButton.addEventListener(
730
"click",
731
event => {
732
if (!event.isTrusted) {
733
return;
734
}
735
event.stopPropagation();
736
737
if (currentLineHeight <= LINE_HEIGHT_MIN) {
738
return;
739
}
740
741
currentLineHeight--;
742
updateControls();
743
this._setLineHeight(currentLineHeight);
744
},
745
true
746
);
747
},
748
749
_handleDeviceLight(newLux) {
750
// Desired size of the this._luxValues array.
751
let luxValuesSize = 10;
752
// Add new lux value at the front of the array.
753
this._luxValues.unshift(newLux);
754
// Add new lux value to this._totalLux for averaging later.
755
this._totalLux += newLux;
756
757
// Don't update when length of array is less than luxValuesSize except when it is 1.
758
if (this._luxValues.length < luxValuesSize) {
759
// Use the first lux value to set the color scheme until our array equals luxValuesSize.
760
if (this._luxValues.length == 1) {
761
this._updateColorScheme(newLux);
762
}
763
return;
764
}
765
// Holds the average of the lux values collected in this._luxValues.
766
let averageLuxValue = this._totalLux / luxValuesSize;
767
768
this._updateColorScheme(averageLuxValue);
769
// Pop the oldest value off the array.
770
let oldLux = this._luxValues.pop();
771
// Subtract oldLux since it has been discarded from the array.
772
this._totalLux -= oldLux;
773
},
774
775
_handleVisibilityChange() {
776
let colorScheme = Services.prefs.getCharPref("reader.color_scheme");
777
if (colorScheme != "auto") {
778
return;
779
}
780
781
// Turn off the ambient light sensor if the page is hidden
782
this._enableAmbientLighting(!this._doc.hidden);
783
},
784
785
// Setup or teardown the ambient light tracking system.
786
_enableAmbientLighting(enable) {
787
if (enable) {
788
this._win.addEventListener("devicelight", this);
789
this._luxValues = [];
790
this._totalLux = 0;
791
} else {
792
this._win.removeEventListener("devicelight", this);
793
delete this._luxValues;
794
delete this._totalLux;
795
}
796
},
797
798
_updateColorScheme(luxValue) {
799
// Upper bound value for "dark" color scheme beyond which it changes to "light".
800
let upperBoundDark = 50;
801
// Lower bound value for "light" color scheme beyond which it changes to "dark".
802
let lowerBoundLight = 10;
803
// Threshold for color scheme change.
804
let colorChangeThreshold = 20;
805
806
// Ignore changes that are within a certain threshold of previous lux values.
807
if (
808
(this._colorScheme === "dark" && luxValue < upperBoundDark) ||
809
(this._colorScheme === "light" && luxValue > lowerBoundLight)
810
) {
811
return;
812
}
813
814
if (luxValue < colorChangeThreshold) {
815
this._setColorScheme("dark");
816
} else {
817
this._setColorScheme("light");
818
}
819
},
820
821
_setColorScheme(newColorScheme) {
822
// "auto" is not a real color scheme
823
if (this._colorScheme === newColorScheme || newColorScheme === "auto") {
824
return;
825
}
826
827
let bodyClasses = this._doc.body.classList;
828
829
if (this._colorScheme) {
830
bodyClasses.remove(this._colorScheme);
831
}
832
833
this._colorScheme = newColorScheme;
834
bodyClasses.add(this._colorScheme);
835
},
836
837
// Pref values include "dark", "light", and "auto", which automatically switches
838
// between light and dark color schemes based on the ambient light level.
839
_setColorSchemePref(colorSchemePref) {
840
this._enableAmbientLighting(colorSchemePref === "auto");
841
this._setColorScheme(colorSchemePref);
842
843
AsyncPrefs.set("reader.color_scheme", colorSchemePref);
844
},
845
846
_setFontType(newFontType) {
847
if (this._fontType === newFontType) {
848
return;
849
}
850
851
let bodyClasses = this._doc.body.classList;
852
853
if (this._fontType) {
854
bodyClasses.remove(this._fontType);
855
}
856
857
this._fontType = newFontType;
858
bodyClasses.add(this._fontType);
859
860
AsyncPrefs.set("reader.font_type", this._fontType);
861
},
862
863
_setSystemUIVisibility(visible) {
864
this._mm.sendAsyncMessage("Reader:SystemUIVisibility", { visible });
865
},
866
867
_setToolbarVisibility(visible) {
868
let tb = this._toolbarElement;
869
870
if (visible) {
871
if (tb.style.opacity != "1") {
872
tb.removeAttribute("hidden");
873
tb.style.opacity = "1";
874
}
875
} else if (tb.style.opacity != "0") {
876
tb.addEventListener(
877
"transitionend",
878
evt => {
879
if (tb.style.opacity == "0") {
880
tb.setAttribute("hidden", "");
881
}
882
},
883
{ once: true }
884
);
885
tb.style.opacity = "0";
886
}
887
},
888
889
async _loadArticle() {
890
let url = this._getOriginalUrl();
891
this._showProgressDelayed();
892
893
let article;
894
if (this._articlePromise) {
895
article = await this._articlePromise;
896
} else {
897
try {
898
article = await this._getArticle(url);
899
} catch (e) {
900
if (e && e.newURL) {
901
let readerURL = "about:reader?url=" + encodeURIComponent(e.newURL);
902
this._win.location.replace(readerURL);
903
return;
904
}
905
}
906
}
907
908
if (this._windowUnloaded) {
909
return;
910
}
911
912
// Replace the loading message with an error message if there's a failure.
913
// Users are supposed to navigate away by themselves (because we cannot
914
// remove ourselves from session history.)
915
if (!article) {
916
this._showError();
917
return;
918
}
919
920
this._showContent(article);
921
},
922
923
_getArticle(url) {
924
if (this.PLATFORM_HAS_CACHE) {
925
return new Promise((resolve, reject) => {
926
let listener = message => {
927
this._mm.removeMessageListener("Reader:ArticleData", listener);
928
if (message.data.newURL) {
929
reject({ newURL: message.data.newURL });
930
return;
931
}
932
resolve(message.data.article);
933
};
934
this._mm.addMessageListener("Reader:ArticleData", listener);
935
this._mm.sendAsyncMessage("Reader:ArticleGet", { url });
936
});
937
}
938
return ReaderMode.downloadAndParseDocument(url);
939
},
940
941
_requestFavicon() {
942
let handleFaviconReturn = message => {
943
this._mm.removeMessageListener(
944
"Reader:FaviconReturn",
945
handleFaviconReturn
946
);
947
this._loadFavicon(message.data.url, message.data.faviconUrl);
948
};
949
950
this._mm.addMessageListener("Reader:FaviconReturn", handleFaviconReturn);
951
this._mm.sendAsyncMessage("Reader:FaviconRequest", {
952
url: this._article.url,
953
preferredWidth: 16 * this._win.devicePixelRatio,
954
});
955
},
956
957
_loadFavicon(url, faviconUrl) {
958
if (this._article.url !== url) {
959
return;
960
}
961
962
let doc = this._doc;
963
964
let link = doc.createElement("link");
965
link.rel = "shortcut icon";
966
link.href = faviconUrl;
967
968
doc.getElementsByTagName("head")[0].appendChild(link);
969
},
970
971
_updateImageMargins() {
972
let windowWidth = this._win.innerWidth;
973
let bodyWidth = this._doc.body.clientWidth;
974
975
let setImageMargins = function(img) {
976
// If the image is at least as wide as the window, make it fill edge-to-edge on mobile.
977
if (img.naturalWidth >= windowWidth) {
978
img.setAttribute("moz-reader-full-width", true);
979
} else {
980
img.removeAttribute("moz-reader-full-width");
981
}
982
983
// If the image is at least half as wide as the body, center it on desktop.
984
if (img.naturalWidth >= bodyWidth / 2) {
985
img.setAttribute("moz-reader-center", true);
986
} else {
987
img.removeAttribute("moz-reader-center");
988
}
989
};
990
991
let imgs = this._doc.querySelectorAll(this._BLOCK_IMAGES_SELECTOR);
992
for (let i = imgs.length; --i >= 0; ) {
993
let img = imgs[i];
994
995
if (img.naturalWidth > 0) {
996
setImageMargins(img);
997
} else {
998
img.onload = function() {
999
setImageMargins(img);
1000
};
1001
}
1002
}
1003
},
1004
1005
_maybeSetTextDirection: function Read_maybeSetTextDirection(article) {
1006
if (article.dir) {
1007
// Set "dir" attribute on content
1008
this._contentElement.setAttribute("dir", article.dir);
1009
this._headerElement.setAttribute("dir", article.dir);
1010
1011
// The native locale could be set differently than the article's text direction.
1012
var localeDirection = Services.locale.isAppLocaleRTL ? "rtl" : "ltr";
1013
this._readTimeElement.setAttribute("dir", localeDirection);
1014
this._readTimeElement.style.textAlign =
1015
article.dir == "rtl" ? "right" : "left";
1016
}
1017
},
1018
1019
_formatReadTime(slowEstimate, fastEstimate) {
1020
let displayStringKey = "aboutReader.estimatedReadTimeRange1";
1021
1022
// only show one reading estimate when they are the same value
1023
if (slowEstimate == fastEstimate) {
1024
displayStringKey = "aboutReader.estimatedReadTimeValue1";
1025
}
1026
1027
return PluralForm.get(
1028
slowEstimate,
1029
gStrings.GetStringFromName(displayStringKey)
1030
)
1031
.replace("#1", fastEstimate)
1032
.replace("#2", slowEstimate);
1033
},
1034
1035
_showError() {
1036
this._headerElement.classList.remove("reader-show-element");
1037
this._contentElement.classList.remove("reader-show-element");
1038
1039
let errorMessage = gStrings.GetStringFromName("aboutReader.loadError");
1040
this._messageElement.textContent = errorMessage;
1041
this._messageElement.style.display = "block";
1042
1043
this._doc.title = errorMessage;
1044
1045
this._doc.documentElement.dataset.isError = true;
1046
1047
this._error = true;
1048
1049
this._doc.dispatchEvent(
1050
new this._win.CustomEvent("AboutReaderContentError", {
1051
bubbles: true,
1052
cancelable: false,
1053
})
1054
);
1055
},
1056
1057
// This function is the JS version of Java's StringUtils.stripCommonSubdomains.
1058
_stripHost(host) {
1059
if (!host) {
1060
return host;
1061
}
1062
1063
let start = 0;
1064
1065
if (host.startsWith("www.")) {
1066
start = 4;
1067
} else if (host.startsWith("m.")) {
1068
start = 2;
1069
} else if (host.startsWith("mobile.")) {
1070
start = 7;
1071
}
1072
1073
return host.substring(start);
1074
},
1075
1076
_showContent(article) {
1077
this._messageElement.classList.remove("reader-show-element");
1078
1079
this._article = article;
1080
1081
this._domainElement.href = article.url;
1082
let articleUri = Services.io.newURI(article.url);
1083
this._domainElement.textContent = this._stripHost(articleUri.host);
1084
this._creditsElement.textContent = article.byline;
1085
1086
this._titleElement.textContent = article.title;
1087
this._readTimeElement.textContent = this._formatReadTime(
1088
article.readingTimeMinsSlow,
1089
article.readingTimeMinsFast
1090
);
1091
this._doc.title = article.title;
1092
1093
this._headerElement.classList.add("reader-show-element");
1094
1095
let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
1096
Ci.nsIParserUtils
1097
);
1098
let contentFragment = parserUtils.parseFragment(
1099
article.content,
1100
Ci.nsIParserUtils.SanitizerDropForms |
1101
Ci.nsIParserUtils.SanitizerAllowStyle,
1102
false,
1103
articleUri,
1104
this._contentElement
1105
);
1106
this._contentElement.innerHTML = "";
1107
this._contentElement.appendChild(contentFragment);
1108
this._maybeSetTextDirection(article);
1109
this._foundLanguage(article.language);
1110
1111
this._contentElement.classList.add("reader-show-element");
1112
this._updateImageMargins();
1113
1114
this._requestFavicon();
1115
this._doc.body.classList.add("loaded");
1116
1117
this._goToReference(articleUri.ref);
1118
1119
Services.obs.notifyObservers(this._win, "AboutReader:Ready");
1120
1121
this._doc.dispatchEvent(
1122
new this._win.CustomEvent("AboutReaderContentReady", {
1123
bubbles: true,
1124
cancelable: false,
1125
})
1126
);
1127
},
1128
1129
_hideContent() {
1130
this._headerElement.classList.remove("reader-show-element");
1131
this._contentElement.classList.remove("reader-show-element");
1132
},
1133
1134
_showProgressDelayed() {
1135
this._win.setTimeout(() => {
1136
// No need to show progress if the article has been loaded,
1137
// if the window has been unloaded, or if there was an error
1138
// trying to load the article.
1139
if (this._article || this._windowUnloaded || this._error) {
1140
return;
1141
}
1142
1143
this._headerElement.classList.remove("reader-show-element");
1144
this._contentElement.classList.remove("reader-show-element");
1145
1146
this._messageElement.textContent = gStrings.GetStringFromName(
1147
"aboutReader.loading2"
1148
);
1149
this._messageElement.classList.add("reader-show-element");
1150
}, 300);
1151
},
1152
1153
/**
1154
* Returns the original article URL for this about:reader view.
1155
*/
1156
_getOriginalUrl(win) {
1157
let url = win ? win.location.href : this._win.location.href;
1158
return ReaderMode.getOriginalUrl(url) || url;
1159
},
1160
1161
_setupSegmentedButton(id, options, initialValue, callback) {
1162
let doc = this._doc;
1163
let segmentedButton = doc.getElementsByClassName(id)[0];
1164
1165
for (let i = 0; i < options.length; i++) {
1166
let option = options[i];
1167
1168
let item = doc.createElement("button");
1169
1170
// Put the name in a div so that Android can hide it.
1171
let div = doc.createElement("div");
1172
div.textContent = option.name;
1173
div.classList.add("name");
1174
item.appendChild(div);
1175
1176
if (option.itemClass !== undefined) {
1177
item.classList.add(option.itemClass);
1178
}
1179
1180
if (option.description !== undefined) {
1181
let description = doc.createElement("div");
1182
description.textContent = option.description;
1183
description.classList.add("description");
1184
item.appendChild(description);
1185
}
1186
1187
segmentedButton.appendChild(item);
1188
1189
item.addEventListener(
1190
"click",
1191
function(aEvent) {
1192
if (!aEvent.isTrusted) {
1193
return;
1194
}
1195
1196
aEvent.stopPropagation();
1197
1198
// Just pass the ID of the button as an extra and hope the ID doesn't change
1199
// unless the context changes
1200
UITelemetry.addEvent("action.1", "button", null, id);
1201
1202
let items = segmentedButton.children;
1203
for (let j = items.length - 1; j >= 0; j--) {
1204
items[j].classList.remove("selected");
1205
}
1206
1207
item.classList.add("selected");
1208
callback(option.value);
1209
},
1210
true
1211
);
1212
1213
if (option.value === initialValue) {
1214
item.classList.add("selected");
1215
}
1216
}
1217
},
1218
1219
_setupButton(id, callback, titleEntity, textEntity) {
1220
if (titleEntity) {
1221
this._setButtonTip(id, titleEntity);
1222
}
1223
1224
let button = this._doc.getElementsByClassName(id)[0];
1225
if (textEntity) {
1226
button.textContent = gStrings.GetStringFromName(textEntity);
1227
}
1228
button.removeAttribute("hidden");
1229
button.addEventListener(
1230
"click",
1231
function(aEvent) {
1232
if (!aEvent.isTrusted) {
1233
return;
1234
}
1235
1236
let btn = aEvent.target;
1237
callback(btn);
1238
},
1239
true
1240
);
1241
},
1242
1243
/**
1244
* Sets a toolTip for a button. Performed at initial button setup
1245
* and dynamically as button state changes.
1246
* @param Localizable string providing UI element usage tip.
1247
*/
1248
_setButtonTip(id, titleEntity) {
1249
let button = this._doc.getElementsByClassName(id)[0];
1250
button.setAttribute("title", gStrings.GetStringFromName(titleEntity));
1251
},
1252
1253
_setupStyleDropdown() {
1254
let dropdownToggle = this._doc.querySelector(
1255
".style-dropdown .dropdown-toggle"
1256
);
1257
dropdownToggle.setAttribute(
1258
"title",
1259
gStrings.GetStringFromName("aboutReader.toolbar.typeControls")
1260
);
1261
},
1262
1263
_updatePopupPosition(dropdown) {
1264
let dropdownToggle = dropdown.querySelector(".dropdown-toggle");
1265
let dropdownPopup = dropdown.querySelector(".dropdown-popup");
1266
1267
let toggleHeight = dropdownToggle.offsetHeight;
1268
let toggleTop = dropdownToggle.offsetTop;
1269
let popupTop = toggleTop - toggleHeight / 2;
1270
1271
dropdownPopup.style.top = popupTop + "px";
1272
},
1273
1274
_toggleDropdownClicked(event) {
1275
let dropdown = event.target.closest(".dropdown");
1276
1277
if (!dropdown) {
1278
return;
1279
}
1280
1281
event.stopPropagation();
1282
1283
if (dropdown.classList.contains("open")) {
1284
this._closeDropdowns();
1285
} else {
1286
this._openDropdown(dropdown);
1287
if (this._isToolbarVertical) {
1288
this._updatePopupPosition(dropdown);
1289
}
1290
}
1291
},
1292
1293
/*
1294
* If the ReaderView banner font-dropdown is closed, open it.
1295
*/
1296
_openDropdown(dropdown) {
1297
if (dropdown.classList.contains("open")) {
1298
return;
1299
}
1300
1301
this._closeDropdowns();
1302
1303
// Trigger BackPressListener initialization in Android.
1304
dropdown.classList.add("open");
1305
this._mm.sendAsyncMessage("Reader:DropdownOpened", this.viewId);
1306
},
1307
1308
/*
1309
* If the ReaderView has open dropdowns, close them. If we are closing the
1310
* dropdowns because the page is scrolling, allow popups to stay open with
1311
* the keep-open class.
1312
*/
1313
_closeDropdowns(scrolling) {
1314
let selector = ".dropdown.open";
1315
if (scrolling) {
1316
selector += ":not(.keep-open)";
1317
}
1318
1319
let openDropdowns = this._doc.querySelectorAll(selector);
1320
for (let dropdown of openDropdowns) {
1321
dropdown.classList.remove("open");
1322
}
1323
1324
// Trigger BackPressListener cleanup in Android.
1325
if (openDropdowns.length) {
1326
this._mm.sendAsyncMessage("Reader:DropdownClosed", this.viewId);
1327
}
1328
},
1329
1330
/*
1331
* Scroll reader view to a reference
1332
*/
1333
_goToReference(ref) {
1334
if (ref) {
1335
this._win.location.hash = ref;
1336
}
1337
},
1338
};