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