Niedzielski has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/362128 )

Change subject: WIP: New: lazy image loading
......................................................................

WIP: New: lazy image loading

Bug: T164429
Depends-On: Ib97804ac30b66a48d4c5d1a620648ede1cc7bd06
Change-Id: Icf623c6f1e77f52db39a11e1c5de4748850a7100
---
M app/src/main/assets/bundle.js
M app/src/main/assets/styles.css
M app/src/main/assets/wikimedia-page-library.css
M www/js/sections.js
M www/js/transforms/collapseTables.js
5 files changed, 1,339 insertions(+), 460 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/apps/android/wikipedia 
refs/changes/28/362128/1

diff --git a/app/src/main/assets/bundle.js b/app/src/main/assets/bundle.js
index 2d2593f..ded1771 100644
--- a/app/src/main/assets/bundle.js
+++ b/app/src/main/assets/bundle.js
@@ -1,4 +1,1228 @@
 (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof 
require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var 
f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var 
l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return 
s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof 
require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return 
s})({1:[function(require,module,exports){
+(function (global, factory) {
+       typeof exports === 'object' && typeof module !== 'undefined' ? 
module.exports = factory() :
+       typeof define === 'function' && define.amd ? define(factory) :
+       (global.pagelib = factory());
+}(this, (function () { 'use strict';
+
+// This file exists for CSS packaging only. It imports the CSS which is to be
+// packaged in the override CSS build product.
+
+// todo: delete Empty.css when other overrides exist
+
+/**
+ * Polyfill function that tells whether a given element matches a selector.
+ * @param {!Element} el Element
+ * @param {!string} selector Selector to look for
+ * @return {!boolean} Whether the element matches the selector
+ */
+var matchesSelectorCompat = function matchesSelectorCompat(el, selector) {
+  if (el.matches) {
+    return el.matches(selector);
+  }
+  if (el.matchesSelector) {
+    return el.matchesSelector(selector);
+  }
+  if (el.webkitMatchesSelector) {
+    return el.webkitMatchesSelector(selector);
+  }
+  return false;
+};
+
+/**
+ * Returns closest ancestor of element which matches selector.
+ * Similar to 'closest' methods as seen here:
+ *  https://api.jquery.com/closest/
+ *  https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
+ * @param  {!Element} el        Element
+ * @param  {!string} selector   Selector to look for in ancestors of 'el'
+ * @return {?HTMLElement}       Closest ancestor of 'el' matching 'selector'
+ */
+var findClosestAncestor = function findClosestAncestor(el, selector) {
+  var parentElement = void 0;
+  for (parentElement = el.parentElement; parentElement && 
!matchesSelectorCompat(parentElement, selector); parentElement = 
parentElement.parentElement) {
+    // Intentionally empty.
+  }
+  return parentElement;
+};
+
+/**
+ * Determines if element has a table ancestor.
+ * @param  {!Element}  el   Element
+ * @return {boolean}        Whether table ancestor of 'el' is found
+ */
+var isNestedInTable = function isNestedInTable(el) {
+  return Boolean(findClosestAncestor(el, 'table'));
+};
+
+/**
+ * @param {!HTMLElement} element
+ * @return {!boolean} true if element affects layout, false otherwise.
+ */
+var isVisible = function isVisible(element) {
+  return (
+    // 
https://github.com/jquery/jquery/blob/305f193/src/css/hiddenVisibleSelectors.js#L12
+    Boolean(element.offsetWidth || element.offsetHeight || 
element.getClientRects().length)
+  );
+};
+
+/**
+ * @param {!Element} element
+ * @param {!Rectangle} rectangle A rectangle relative the viewport.
+ * @return {!boolean} true if element and rectangle overlap, false otherwise.
+ */
+var intersectsViewportRectangle = function 
intersectsViewportRectangle(element, rectangle) {
+  var bounds = element.getBoundingClientRect();
+  return !(bounds.top > rectangle.bottom || bounds.right < rectangle.left || 
bounds.bottom < rectangle.top || bounds.left > rectangle.right);
+};
+
+/**
+ * Copy attributes from source to destination as data-* attributes.
+ * @param {!HTMLElement} source
+ * @param {!HTMLElement} destination
+ * @param {!string[]} attributes
+ * @return {void}
+ */
+var copyAttributesToDataAttributes = function 
copyAttributesToDataAttributes(source, destination, attributes) {
+  var _iteratorNormalCompletion = true;
+  var _didIteratorError = false;
+  var _iteratorError = undefined;
+
+  try {
+    for (var _iterator = attributes[Symbol.iterator](), _step; 
!(_iteratorNormalCompletion = (_step = _iterator.next()).done); 
_iteratorNormalCompletion = true) {
+      var attribute = _step.value;
+
+      if (source.hasAttribute(attribute)) {
+        destination.setAttribute('data-' + attribute, 
source.getAttribute(attribute));
+      }
+    }
+  } catch (err) {
+    _didIteratorError = true;
+    _iteratorError = err;
+  } finally {
+    try {
+      if (!_iteratorNormalCompletion && _iterator.return) {
+        _iterator.return();
+      }
+    } finally {
+      if (_didIteratorError) {
+        throw _iteratorError;
+      }
+    }
+  }
+};
+
+/**
+ * Copy data-* attributes from source to destination as attributes.
+ * @param {!HTMLElement} source
+ * @param {!HTMLElement} destination
+ * @param {!string[]} attributes
+ * @return {void}
+ */
+var copyDataAttributesToAttributes = function 
copyDataAttributesToAttributes(source, destination, attributes) {
+  var _iteratorNormalCompletion2 = true;
+  var _didIteratorError2 = false;
+  var _iteratorError2 = undefined;
+
+  try {
+    for (var _iterator2 = attributes[Symbol.iterator](), _step2; 
!(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); 
_iteratorNormalCompletion2 = true) {
+      var attribute = _step2.value;
+
+      var dataAttribute = 'data-' + attribute;
+      if (source.hasAttribute(dataAttribute)) {
+        destination.setAttribute(attribute, 
source.getAttribute(dataAttribute));
+      }
+    }
+  } catch (err) {
+    _didIteratorError2 = true;
+    _iteratorError2 = err;
+  } finally {
+    try {
+      if (!_iteratorNormalCompletion2 && _iterator2.return) {
+        _iterator2.return();
+      }
+    } finally {
+      if (_didIteratorError2) {
+        throw _iteratorError2;
+      }
+    }
+  }
+};
+
+var elementUtilities = {
+  matchesSelectorCompat: matchesSelectorCompat,
+  findClosestAncestor: findClosestAncestor,
+  isNestedInTable: isNestedInTable,
+  isVisible: isVisible,
+  intersectsViewportRectangle: intersectsViewportRectangle,
+  copyAttributesToDataAttributes: copyAttributesToDataAttributes,
+  copyDataAttributesToAttributes: copyDataAttributesToAttributes
+};
+
+var SECTION_TOGGLED_EVENT_TYPE = 'section-toggled';
+
+/**
+ * Find an array of table header (TH) contents. If there are no TH elements in
+ * the table or the header's link matches pageTitle, an empty array is 
returned.
+ * @param {!Element} element
+ * @param {?string} pageTitle Unencoded page title; if this title matches the
+ *                            contents of the header exactly, it will be 
omitted.
+ * @return {!Array<string>}
+ */
+var getTableHeader = function getTableHeader(element, pageTitle) {
+  var thArray = [];
+
+  if (!element.children) {
+    return thArray;
+  }
+
+  for (var i = 0; i < element.children.length; i++) {
+    var el = element.children[i];
+
+    if (el.tagName === 'TH') {
+      // ok, we have a TH element!
+      // However, if it contains more than two links, then ignore it, because
+      // it will probably appear weird when rendered as plain text.
+      var aNodes = el.querySelectorAll('a');
+      // todo: these conditionals are very confusing. Rewrite by extracting a
+      //       method or simplify.
+      if (aNodes.length < 3) {
+        // todo: remove nonstandard Element.innerText usage
+        // Also ignore it if it's identical to the page title.
+        if ((el.innerText && el.innerText.length || el.textContent.length) > 0 
&& el.innerText !== pageTitle && el.textContent !== pageTitle && el.innerHTML 
!== pageTitle) {
+          thArray.push(el.innerText || el.textContent);
+        }
+      }
+    }
+
+    // if it's a table within a table, don't worry about it
+    if (el.tagName === 'TABLE') {
+      continue;
+    }
+
+    // todo: why do we need to recurse?
+    // recurse into children of this element
+    var ret = getTableHeader(el, pageTitle);
+
+    // did we get a list of TH from this child?
+    if (ret.length > 0) {
+      thArray = thArray.concat(ret);
+    }
+  }
+
+  return thArray;
+};
+
+/**
+ * @typedef {function} FooterDivClickCallback
+ * @param {!HTMLElement}
+ * @return {void}
+ */
+
+/**
+ * Ex: toggleCollapseClickCallback.bind(el, (container) => {
+ *       window.scrollTo(0, container.offsetTop - transformer.getDecorOffset())
+ *     })
+ * @this HTMLElement
+ * @param {?FooterDivClickCallback} footerDivClickCallback
+ * @return {boolean} true if collapsed, false if expanded.
+ */
+var toggleCollapseClickCallback = function 
toggleCollapseClickCallback(footerDivClickCallback) {
+  var container = this.parentNode;
+  var header = container.children[0];
+  var table = container.children[1];
+  var footer = container.children[2];
+  var caption = header.querySelector('.app_table_collapsed_caption');
+  var collapsed = table.style.display !== 'none';
+  if (collapsed) {
+    table.style.display = 'none';
+    header.classList.remove('app_table_collapse_close'); // todo: use 
app_table_collapsed_collapsed
+    header.classList.remove('app_table_collapse_icon'); // todo: use 
app_table_collapsed_icon
+    header.classList.add('app_table_collapsed_open'); // todo: use 
app_table_collapsed_expanded
+    if (caption) {
+      caption.style.visibility = 'visible';
+    }
+    footer.style.display = 'none';
+    // if they clicked the bottom div, then scroll back up to the top of the 
table.
+    if (this === footer && footerDivClickCallback) {
+      footerDivClickCallback(container);
+    }
+  } else {
+    table.style.display = 'block';
+    header.classList.remove('app_table_collapsed_open'); // todo: use 
app_table_collapsed_expanded
+    header.classList.add('app_table_collapse_close'); // todo: use 
app_table_collapsed_collapsed
+    header.classList.add('app_table_collapse_icon'); // todo: use 
app_table_collapsed_icon
+    if (caption) {
+      caption.style.visibility = 'hidden';
+    }
+    footer.style.display = 'block';
+  }
+  return collapsed;
+};
+
+/**
+ * @param {!HTMLElement} table
+ * @return {!boolean} true if table should be collapsed, false otherwise.
+ */
+var shouldTableBeCollapsed = function shouldTableBeCollapsed(table) {
+  var classBlacklist = ['navbox', 'vertical-navbox', 'navbox-inner', 
'metadata', 'mbox-small'];
+  var blacklistIntersects = classBlacklist.some(function (clazz) {
+    return table.classList.contains(clazz);
+  });
+  return table.style.display !== 'none' && !blacklistIntersects;
+};
+
+/**
+ * @param {!Element} element
+ * @return {!boolean} true if element is an infobox, false otherwise.
+ */
+var isInfobox = function isInfobox(element) {
+  return element.classList.contains('infobox');
+};
+
+/**
+ * @param {!Document} document
+ * @param {?string} content HTML string.
+ * @return {!HTMLDivElement}
+ */
+var newCollapsedHeaderDiv = function newCollapsedHeaderDiv(document, content) {
+  var div = document.createElement('div');
+  div.classList.add('app_table_collapsed_container');
+  div.classList.add('app_table_collapsed_open');
+  div.innerHTML = content || '';
+  return div;
+};
+
+/**
+ * @param {!Document} document
+ * @param {?string} content HTML string.
+ * @return {!HTMLDivElement}
+ */
+var newCollapsedFooterDiv = function newCollapsedFooterDiv(document, content) {
+  var div = document.createElement('div');
+  div.classList.add('app_table_collapsed_bottom');
+  div.classList.add('app_table_collapse_icon'); // todo: use collapsed 
everywhere
+  div.innerHTML = content || '';
+  return div;
+};
+
+/**
+ * @param {!string} title
+ * @param {!string[]} headerText
+ * @return {!string} HTML string.
+ */
+var newCaption = function newCaption(title, headerText) {
+  var caption = '<strong>' + title + '</strong>';
+
+  caption += '<span class=app_span_collapse_text>';
+  if (headerText.length > 0) {
+    caption += ': ' + headerText[0];
+  }
+  if (headerText.length > 1) {
+    caption += ', ' + headerText[1];
+  }
+  if (headerText.length > 0) {
+    caption += ' …';
+  }
+  caption += '</span>';
+
+  return caption;
+};
+
+/**
+ * @param {!Window} window
+ * @param {!Element} content
+ * @param {?string} pageTitle
+ * @param {?boolean} isMainPage
+ * @param {?string} infoboxTitle
+ * @param {?string} otherTitle
+ * @param {?string} footerTitle
+ * @param {?FooterDivClickCallback} footerDivClickCallback
+ * @return {void}
+ */
+var collapseTables = function collapseTables(window, content, pageTitle, 
isMainPage, infoboxTitle, otherTitle, footerTitle, footerDivClickCallback) {
+  if (isMainPage) {
+    return;
+  }
+
+  var tables = content.querySelectorAll('table');
+
+  var _loop = function _loop(i) {
+    var table = tables[i];
+
+    if (elementUtilities.findClosestAncestor(table, '.app_table_container') || 
!shouldTableBeCollapsed(table)) {
+      return 'continue';
+    }
+
+    // todo: this is actually an array
+    var headerText = getTableHeader(table, pageTitle);
+    if (!headerText.length && !isInfobox(table)) {
+      return 'continue';
+    }
+    var caption = newCaption(isInfobox(table) ? infoboxTitle : otherTitle, 
headerText);
+
+    // create the container div that will contain both the original table
+    // and the collapsed version.
+    var containerDiv = window.document.createElement('div');
+    containerDiv.className = 'app_table_container';
+    table.parentNode.insertBefore(containerDiv, table);
+    table.parentNode.removeChild(table);
+
+    // remove top and bottom margin from the table, so that it's flush with
+    // our expand/collapse buttons
+    table.style.marginTop = '0px';
+    table.style.marginBottom = '0px';
+
+    var collapsedHeaderDiv = newCollapsedHeaderDiv(window.document, caption);
+    collapsedHeaderDiv.style.display = 'block';
+
+    var collapsedFooterDiv = newCollapsedFooterDiv(window.document, 
footerTitle);
+    collapsedFooterDiv.style.display = 'none';
+
+    // add our stuff to the container
+    containerDiv.appendChild(collapsedHeaderDiv);
+    containerDiv.appendChild(table);
+    containerDiv.appendChild(collapsedFooterDiv);
+
+    // set initial visibility
+    table.style.display = 'none';
+
+    // eslint-disable-next-line require-jsdoc, no-loop-func
+    var dispatchSectionToggledEvent = function 
dispatchSectionToggledEvent(collapsed) {
+      return (
+        // eslint-disable-next-line no-undef
+        window.dispatchEvent(new CustomEvent(SECTION_TOGGLED_EVENT_TYPE, { 
collapsed: collapsed }))
+      );
+    };
+
+    // assign click handler to the collapsed divs
+    collapsedHeaderDiv.onclick = function () {
+      var collapsed = toggleCollapseClickCallback.bind(collapsedHeaderDiv)();
+      dispatchSectionToggledEvent(collapsed);
+    };
+    collapsedFooterDiv.onclick = function () {
+      var collapsed = toggleCollapseClickCallback.bind(collapsedFooterDiv, 
footerDivClickCallback)();
+      dispatchSectionToggledEvent(collapsed);
+    };
+  };
+
+  for (var i = 0; i < tables.length; ++i) {
+    var _ret = _loop(i);
+
+    if (_ret === 'continue') continue;
+  }
+};
+
+/**
+ * If you tap a reference targeting an anchor within a collapsed table, this
+ * method will expand the references section. The client can then scroll to the
+ * references section.
+ *
+ * The first reference (an "[A]") in the "enwiki > Airplane" article from ~June
+ * 2016 exhibits this issue. (You can copy wikitext from this revision into a
+ * test wiki page for testing.)
+ * @param  {?Element} element
+ * @return {void}
+*/
+var expandCollapsedTableIfItContainsElement = function 
expandCollapsedTableIfItContainsElement(element) {
+  if (element) {
+    var containerSelector = '[class*="app_table_container"]';
+    var container = elementUtilities.findClosestAncestor(element, 
containerSelector);
+    if (container) {
+      var collapsedDiv = container.firstElementChild;
+      if (collapsedDiv && 
collapsedDiv.classList.contains('app_table_collapsed_open')) {
+        collapsedDiv.click();
+      }
+    }
+  }
+};
+
+var CollapseTable = {
+  SECTION_TOGGLED_EVENT_TYPE: SECTION_TOGGLED_EVENT_TYPE,
+  toggleCollapseClickCallback: toggleCollapseClickCallback,
+  collapseTables: collapseTables,
+  expandCollapsedTableIfItContainsElement: 
expandCollapsedTableIfItContainsElement,
+  test: {
+    getTableHeader: getTableHeader,
+    shouldTableBeCollapsed: shouldTableBeCollapsed,
+    isInfobox: isInfobox,
+    newCollapsedHeaderDiv: newCollapsedHeaderDiv,
+    newCollapsedFooterDiv: newCollapsedFooterDiv,
+    newCaption: newCaption
+  }
+};
+
+var _createClass$1 = function () { function defineProperties(target, props) { 
for (var i = 0; i < props.length; i++) { var descriptor = props[i]; 
descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable 
= true; if ("value" in descriptor) descriptor.writable = true; 
Object.defineProperty(target, descriptor.key, descriptor); } } return function 
(Constructor, protoProps, staticProps) { if (protoProps) 
defineProperties(Constructor.prototype, protoProps); if (staticProps) 
defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+function _classCallCheck$1(instance, Constructor) { if (!(instance instanceof 
Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+/** Function rate limiter. */
+var Throttle = function () {
+  _createClass$1(Throttle, null, [{
+    key: "wrap",
+
+    /**
+     * Wraps a function in a Throttle.
+     * @param {!Window} window
+     * @param {!number} period The nonnegative minimum number of milliseconds 
between function
+     *                         invocations.
+     * @param {!function} funktion The function to invoke when not throttled.
+     * @return {!function} A function wrapped in a Throttle.
+     */
+    value: function wrap(window, period, funktion) {
+      var throttle = new Throttle(window, period, funktion);
+      var throttled = function Throttled() {
+        return throttle.queue(this, arguments);
+      };
+      throttled.result = function () {
+        return throttle.result;
+      };
+      throttled.pending = function () {
+        return throttle.pending();
+      };
+      throttled.delay = function () {
+        return throttle.delay();
+      };
+      throttled.cancel = function () {
+        return throttle.cancel();
+      };
+      throttled.reset = function () {
+        return throttle.reset();
+      };
+      return throttled;
+    }
+
+    /**
+     * @param {!Window} window
+     * @param {!number} period The nonnegative minimum number of milliseconds 
between function
+     *                         invocations.
+     * @param {!function} funktion The function to invoke when not throttled.
+     */
+
+  }]);
+
+  function Throttle(window, period, funktion) {
+    _classCallCheck$1(this, Throttle);
+
+    this._window = window;
+    this._period = period;
+    this._function = funktion;
+
+    // The upcoming invocation's context and arguments.
+    this._context = undefined;
+    this._arguments = undefined;
+
+    // The previous invocation's result, timeout identifier, and last run 
timestamp.
+    this._result = undefined;
+    this._timeout = 0;
+    this._timestamp = 0;
+  }
+
+  /**
+   * The return value of the initial run is always undefined. The return value 
of subsequent runs is
+   * always a previous result. The context and args used by a future 
invocation are always the most
+   * recently supplied. Invocations, even if immediately eligible, are 
dispatched.
+   * @param {?any} context
+   * @param {?any} args The arguments passed to the underlying function.
+   * @return {?any} The cached return value of the underlying function.
+   */
+
+
+  _createClass$1(Throttle, [{
+    key: "queue",
+    value: function queue(context, args) {
+      var _this = this;
+
+      // Always update the this and arguments to the latest supplied.
+      this._context = context;
+      this._arguments = args;
+
+      if (!this.pending()) {
+        // Queue a new invocation.
+        this._timeout = this._window.setTimeout(function () {
+          _this._timeout = 0;
+          _this._timestamp = Date.now();
+          _this._result = _this._function.apply(_this._context, 
_this._arguments);
+        }, this.delay());
+      }
+
+      // Always return the previous result.
+      return this.result;
+    }
+
+    /** @return {?any} The cached return value of the underlying function. */
+
+  }, {
+    key: "pending",
+
+
+    /** @return {!boolean} true if an invocation is queued. */
+    value: function pending() {
+      return Boolean(this._timeout);
+    }
+
+    /**
+     * @return {!number} The nonnegative number of milliseconds until an 
invocation is eligible to
+     *                   run.
+     */
+
+  }, {
+    key: "delay",
+    value: function delay() {
+      if (!this._timestamp) {
+        return 0;
+      }
+      return Math.max(0, this._period - (Date.now() - this._timestamp));
+    }
+
+    /**
+     * Clears any pending invocation but doesn't clear time last invoked or 
prior result.
+     * @return {void}
+     */
+
+  }, {
+    key: "cancel",
+    value: function cancel() {
+      if (this._timeout) {
+        this._window.clearTimeout(this._timeout);
+      }
+      this._timeout = 0;
+    }
+
+    /**
+     * Clears any pending invocation, time last invoked, and prior result.
+     * @return {void}
+     */
+
+  }, {
+    key: "reset",
+    value: function reset() {
+      this.cancel();
+      this._result = undefined;
+      this._timestamp = 0;
+    }
+  }, {
+    key: "result",
+    get: function get() {
+      return this._result;
+    }
+  }]);
+
+  return Throttle;
+}();
+
+var _createClass = function () { function defineProperties(target, props) { 
for (var i = 0; i < props.length; i++) { var descriptor = props[i]; 
descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable 
= true; if ("value" in descriptor) descriptor.writable = true; 
Object.defineProperty(target, descriptor.key, descriptor); } } return function 
(Constructor, protoProps, staticProps) { if (protoProps) 
defineProperties(Constructor.prototype, protoProps); if (staticProps) 
defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof 
Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+/** This class provides a mechanism to subscribe to rate limited events. */
+
+var _class = function () {
+  /**
+   * @param {!Window} window A dependency of the throttle mechanism.
+   * @param {!number} period The nonnegative minimum number of milliseconds 
between publishing
+   *                         events.
+   */
+  function _class(window, period) {
+    var _this = this;
+
+    _classCallCheck(this, _class);
+
+    this._throttle = Throttle.wrap(window, period, function () {
+      return _this._eventTarget.dispatchEvent(_this._publishEvent);
+    });
+
+    this._eventTarget = undefined;
+    this._subscribeEventType = undefined;
+    this._publishEvent = undefined;
+  }
+
+  /**
+   * @param {!EventTarget} eventTarget The target to subscribe to events from 
and publish throttled
+   *                                   events to.
+   * @param {!string} subscribeEventType The input event type to listen for.
+   * @param {!Event} publishEvent The output event to post.
+   * @return {void}
+   */
+
+
+  _createClass(_class, [{
+    key: 'register',
+    value: function register(eventTarget, subscribeEventType, publishEvent) {
+      this._eventTarget = eventTarget;
+      this._subscribeEventType = subscribeEventType;
+      this._publishEvent = publishEvent;
+      this._eventTarget.addEventListener(subscribeEventType, this._throttle);
+    }
+
+    /** @return {!boolean} */
+
+  }, {
+    key: 'registered',
+    value: function registered() {
+      return Boolean(this._eventTarget);
+    }
+
+    /**
+     * This method may be safely called even when unregistered.
+     * @return {void}
+     */
+
+  }, {
+    key: 'deregister',
+    value: function deregister() {
+      if (!this.registered()) {
+        return;
+      }
+
+      this._eventTarget.removeEventListener(this._subscribeEventType, 
this._throttle);
+      this._publishEvent = undefined;
+      this._subscribeEventType = undefined;
+      this._eventTarget = undefined;
+
+      this._throttle.reset();
+    }
+  }]);
+
+  return _class;
+}();
+
+// CSS classes used to identify and present transformed images. Placeholders 
are always members of
+// the PLACEHOLDER_CLASS and exactly one of PENDING, LOADING, or LOADED, 
depending on the current
+// transform state. These class names should match what's used in 
LazyLoadTransform.css.
+var PLACEHOLDER_CLASS = 'pagelib-lazy-load-placeholder'; // Always present.
+var PLACEHOLDER_PENDING_CLASS = 'pagelib-lazy-load-placeholder-pending'; // 
Download not started.
+var PLACEHOLDER_LOADING_CLASS = 'pagelib-lazy-load-placeholder-loading'; // 
Download started.
+var PLACEHOLDER_LOADED_CLASS = 'pagelib-lazy-load-placeholder-loaded'; // 
Download completed.
+
+// Selector used to identify transformable images. Images must be parented.
+var TRANSFORM_IMAGE_SELECTOR = ':not(.' + PLACEHOLDER_CLASS + ') img';
+
+// Attributes copied from images to placeholders via data-* attributes for 
later restoration.
+var COPY_ATTRIBUTES = ['class', 'style', 'src', 'srcset', 'width', 'height', 
'alt'];
+
+/**
+ * Create and populate a new placeholder from an image.
+ * @param {!Document} document
+ * @param {!HTMLImageElement} image The image to be replaced.
+ * @return {!HTMLSpanElement}
+ */
+var newPlaceholder = function newPlaceholder(document, image) {
+  var placeholder = document.createElement('span');
+
+  elementUtilities.copyAttributesToDataAttributes(image, placeholder, 
COPY_ATTRIBUTES);
+
+  placeholder.classList.add(PLACEHOLDER_CLASS);
+  placeholder.classList.add(PLACEHOLDER_PENDING_CLASS);
+
+  var width = image.hasAttribute('width') ? 'width: ' + 
image.getAttribute('width') + 'px;' : '';
+  var height = image.hasAttribute('height') ? 'height: ' + 
image.getAttribute('height') + 'px;' : '';
+  placeholder.setAttribute('style', width + height);
+
+  return placeholder;
+};
+
+/**
+ * Create and populate a new image from a placeholder.
+ * @param {!Document} document
+ * @param {!HTMLSpanElement} placeholder
+ * @param {!Function} loadEventListener
+ * @return {!HTMLImageElement}
+ */
+var newImageSubstitute = function newImageSubstitute(document, placeholder, 
loadEventListener) {
+  var image = document.createElement('img');
+
+  // Add the download listener prior to setting the src attribute to avoid 
missing the load event.
+  image.addEventListener('load', loadEventListener, { once: true });
+
+  // Set src and other attributes, triggering a download.
+  elementUtilities.copyDataAttributesToAttributes(placeholder, image, 
COPY_ATTRIBUTES);
+
+  return image;
+};
+
+/**
+ * Replace image with placeholder.
+ * @param {!Document} document
+ * @param {!HTMLImageElement} image The image to be replaced. Must be parented.
+ * @return {!HTMLSpanElement} The placeholder that replaced the image.
+ */
+var transformImage = function transformImage(document, image) {
+  // Replace the image and its attributes with a span to prevent the image 
from downloading. A
+  // replacement span is used instead of the image itself for consistency with 
MobileFrontend /
+  // Minerva and because image src is not an animatable property which 
prevents cross-fading with
+  // the background.
+  var placeholder = newPlaceholder(document, image);
+  image.parentNode.replaceChild(placeholder, image);
+
+  // The image still exists in the DOM. Ensure no unused resources are loaded.
+  var _arr = ['src', 'srcset'];
+  for (var _i = 0; _i < _arr.length; _i++) {
+    var attribute = _arr[_i];image.removeAttribute(attribute);
+  }
+
+  return placeholder;
+};
+
+/**
+ * Visually* replace placeholder with a substitute image. The image only 
appears once loaded.
+ *
+ * *The substitute image is actually appended to the placeholder.
+ * @param {!Document} document
+ * @param {!HTMLSpanElement} placeholder The placeholder to replace.
+ * @return {!HTMLImageElement} The substitute image.
+ */
+var loadImage = function loadImage(document, placeholder) {
+  placeholder.classList.remove(PLACEHOLDER_PENDING_CLASS);
+  placeholder.classList.add(PLACEHOLDER_LOADING_CLASS);
+
+  var image = newImageSubstitute(document, placeholder, function () {
+    placeholder.appendChild(image);
+
+    placeholder.classList.remove(PLACEHOLDER_LOADING_CLASS);
+    placeholder.classList.add(PLACEHOLDER_LOADED_CLASS);
+  });
+
+  return image;
+};
+
+/**
+ * @param {!Element} element
+ * @return {!HTMLImageElement[]} Transformable images descendent from but not 
including element.
+ */
+var queryTransformImages = function queryTransformImages(element) {
+  return 
Array.prototype.slice.call(element.querySelectorAll(TRANSFORM_IMAGE_SELECTOR));
+};
+
+/**
+ * Replace images with placeholders. Only image class, style, src, srcset, 
width, height, and alt
+ * attributes are preserved. The transformation is inverted by calling 
loadImages().
+ * @param {!Document} document
+ * @param {!HTMLImageElement[]} images The images to replace.
+ * @return {!HTMLSpanElement[]} Placeholders that replaced images.
+ */
+var transform = function transform(document, images) {
+  return images.map(function (image) {
+    return transformImage(document, image);
+  });
+};
+
+var LazyLoadTransform = {
+  test: { transformImage: transformImage },
+  loadImage: loadImage,
+  queryTransformImages: queryTransformImages,
+  transform: transform
+};
+
+var _createClass$3 = function () { function defineProperties(target, props) { 
for (var i = 0; i < props.length; i++) { var descriptor = props[i]; 
descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable 
= true; if ("value" in descriptor) descriptor.writable = true; 
Object.defineProperty(target, descriptor.key, descriptor); } } return function 
(Constructor, protoProps, staticProps) { if (protoProps) 
defineProperties(Constructor.prototype, protoProps); if (staticProps) 
defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+function _classCallCheck$3(instance, Constructor) { if (!(instance instanceof 
Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+/** A rectangle. */
+var _class$2 = function () {
+  /**
+   * @param {!number} top
+   * @param {!number} right
+   * @param {!number} bottom
+   * @param {!number} left
+   */
+  function _class(top, right, bottom, left) {
+    _classCallCheck$3(this, _class);
+
+    this._top = top;
+    this._right = right;
+    this._bottom = bottom;
+    this._left = left;
+  }
+
+  /** @return {!number} */
+
+
+  _createClass$3(_class, [{
+    key: "toString",
+
+
+    // eslint-disable-next-line require-jsdoc
+    value: function toString() {
+      return "(" + this.left + ", " + this.top + "), (" + this.right + ", " + 
this.bottom + ")";
+    }
+  }, {
+    key: "top",
+    get: function get() {
+      return this._top;
+    }
+
+    /** @return {!number} */
+
+  }, {
+    key: "right",
+    get: function get() {
+      return this._right;
+    }
+
+    /** @return {!number} */
+
+  }, {
+    key: "bottom",
+    get: function get() {
+      return this._bottom;
+    }
+
+    /** @return {!number} */
+
+  }, {
+    key: "left",
+    get: function get() {
+      return this._left;
+    }
+  }]);
+
+  return _class;
+}();
+
+var _createClass$2 = function () { function defineProperties(target, props) { 
for (var i = 0; i < props.length; i++) { var descriptor = props[i]; 
descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable 
= true; if ("value" in descriptor) descriptor.writable = true; 
Object.defineProperty(target, descriptor.key, descriptor); } } return function 
(Constructor, protoProps, staticProps) { if (protoProps) 
defineProperties(Constructor.prototype, protoProps); if (staticProps) 
defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+function _classCallCheck$2(instance, Constructor) { if (!(instance instanceof 
Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+var UNTHROTTLED_RESIZE_EVENT_TYPE = 'resize';
+var UNTHROTTLED_SCROLL_EVENT_TYPE = 'scroll';
+var THROTTLED_RESIZE_EVENT_TYPE = 'resize:lazy-load-throttled';
+var THROTTLED_SCROLL_EVENT_TYPE = 'scroll:lazy-load-throttled';
+
+var THROTTLE_PERIOD_MILLISECONDS = 100;
+
+/**
+ * This class subscribes to key page events, applying lazy load transforms or 
inversions as
+ * applicable. It has external dependencies on the section-toggled custom 
event and the following
+ * standard browser events: resize, scroll.
+ */
+
+var _class$1 = function () {
+  /**
+   * @param {!Window} window
+   * @param {!number} loadDistanceMultiplier viewport distance multiplier.
+   */
+  function _class$$1(window, loadDistanceMultiplier) {
+    var _this = this;
+
+    _classCallCheck$2(this, _class$$1);
+
+    this._window = window;
+
+    this._placeholders = [];
+    this._resizeEventThrottle = new _class(window, 
THROTTLE_PERIOD_MILLISECONDS);
+    this._scrollEventThrottle = new _class(window, 
THROTTLE_PERIOD_MILLISECONDS);
+    this._loadImagesCallback = function () {
+      return 
_this._loadImages(_this._newLoadEligibilityRectangle(loadDistanceMultiplier));
+    };
+  }
+
+  /**
+   * This function may register.
+   * @param {!Element} element
+   * @return {void}
+   */
+
+
+  _createClass$2(_class$$1, [{
+    key: 'transform',
+    value: function transform(element) {
+      var images = LazyLoadTransform.queryTransformImages(element);
+      var placeholders = LazyLoadTransform.transform(this._window.document, 
images);
+      this._placeholders = this._placeholders.concat(placeholders);
+      this._register();
+    }
+
+    /**
+     * This method may be safely called even when already unregistered.
+     * @return {void}
+     */
+
+  }, {
+    key: 'deregister',
+    value: function deregister() {
+      if (!this._registered()) {
+        return;
+      }
+
+      this._window.removeEventListener(THROTTLED_SCROLL_EVENT_TYPE, 
this._loadImagesCallback);
+      this._window.addEventListener(THROTTLED_RESIZE_EVENT_TYPE, 
this._loadImagesCallback);
+      
this._window.removeEventListener(CollapseTable.SECTION_TOGGLED_EVENT_TYPE, 
this._loadImagesCallback);
+
+      this._scrollEventThrottle.deregister();
+      this._resizeEventThrottle.deregister();
+    }
+
+    /**
+     * This method may be safely called even when already registered.
+     * @return {void}
+     */
+
+  }, {
+    key: '_register',
+    value: function _register() {
+      if (this._registered() || !this._placeholders.length) {
+        return;
+      }
+
+      this._resizeEventThrottle.register(this._window, 
UNTHROTTLED_RESIZE_EVENT_TYPE, new CustomEvent(THROTTLED_RESIZE_EVENT_TYPE));
+      this._scrollEventThrottle.register(this._window, 
UNTHROTTLED_SCROLL_EVENT_TYPE, new CustomEvent(THROTTLED_SCROLL_EVENT_TYPE));
+
+      this._window.addEventListener(CollapseTable.SECTION_TOGGLED_EVENT_TYPE, 
this._loadImagesCallback);
+      this._window.addEventListener(THROTTLED_RESIZE_EVENT_TYPE, 
this._loadImagesCallback);
+      this._window.addEventListener(THROTTLED_SCROLL_EVENT_TYPE, 
this._loadImagesCallback);
+    }
+
+    /** @return {!boolean} */
+
+  }, {
+    key: '_registered',
+    value: function _registered() {
+      return this._resizeEventThrottle.registered();
+    }
+
+    /**
+     * @param {!Rectangle} viewport
+     * @return {void}
+     */
+
+  }, {
+    key: '_loadImages',
+    value: function _loadImages(viewport) {
+      var _this2 = this;
+
+      this._placeholders = this._placeholders.filter(function (placeholder) {
+        return !(_this2._isImageEligibleToLoad(placeholder, viewport) && 
LazyLoadTransform.loadImage(_this2._window.document, placeholder));
+      });
+    }
+
+    /**
+     * @param {!HTMLSpanElement} placeholder
+     * @param {!Rectangle} viewport
+     * @return {!boolean}
+     */
+
+  }, {
+    key: '_isImageEligibleToLoad',
+    value: function _isImageEligibleToLoad(placeholder, viewport) {
+      return elementUtilities.isVisible(placeholder) && 
elementUtilities.intersectsViewportRectangle(placeholder, viewport);
+    }
+
+    /**
+     * @return {!Rectangle} The boundaries for images eligible to load 
relative the viewport. Images
+     *                      within these boundaries may already be loading or 
loaded; images outside
+     *                      of these boundaries should not be loaded as 
they're ineligible, however,
+     *                      they may have previously been loaded.
+     */
+
+  }, {
+    key: '_newLoadEligibilityRectangle',
+    value: function _newLoadEligibilityRectangle(loadDistanceMultiplier) {
+      var x = 0;
+      var y = 0;
+      var width = this._window.innerWidth * loadDistanceMultiplier;
+      var height = this._window.innerHeight * loadDistanceMultiplier;
+      return new _class$2(y, x + width, y + height, x);
+    }
+  }]);
+
+  return _class$$1;
+}();
+
+/**
+ * Configures span to be suitable replacement for red link anchor.
+ * @param {!HTMLSpanElement} span The span element to configure as anchor 
replacement.
+ * @param {!HTMLAnchorElement} anchor The anchor element being replaced.
+ * @return {void}
+ */
+var configureRedLinkTemplate = function configureRedLinkTemplate(span, anchor) 
{
+  span.innerHTML = anchor.innerHTML;
+  span.setAttribute('class', anchor.getAttribute('class'));
+};
+
+/**
+ * Finds red links in a document or document fragment.
+ * @param {!(Document|DocumentFragment)} content Document or fragment in which 
to seek red links.
+ * @return {!NodeList} Nodelist of zero or more red link anchors.
+ */
+var redLinkAnchorsInContent = function redLinkAnchorsInContent(content) {
+  return content.querySelectorAll('a.new');
+};
+
+/**
+ * Makes span to be used as cloning template for red link anchor replacements.
+ * @param  {!Document} document Document to use to create span element. 
Reminder: this can't be a
+ * document fragment because fragments don't implement 'createElement'.
+ * @return {!HTMLSpanElement} Span element suitable for use as template for 
red link anchor
+ * replacements.
+ */
+var newRedLinkTemplate = function newRedLinkTemplate(document) {
+  return document.createElement('span');
+};
+
+/**
+ * Replaces anchor with span.
+ * @param  {!HTMLAnchorElement} anchor Anchor element.
+ * @param  {!HTMLSpanElement} span Span element.
+ * @return {void}
+ */
+var replaceAnchorWithSpan = function replaceAnchorWithSpan(anchor, span) {
+  return anchor.parentNode.replaceChild(span, anchor);
+};
+
+/**
+ * Hides red link anchors in either a document or a document fragment so they 
are unclickable and
+ * unfocusable.
+ * @param {!Document} document Document in which to hide red links.
+ * @param {?DocumentFragment} fragment If specified, red links are hidden in 
the fragment and the
+ * document is used only for span cloning.
+ * @return {void}
+ */
+var hideRedLinks = function hideRedLinks(document, fragment) {
+  var spanTemplate = newRedLinkTemplate(document);
+  var content = fragment !== undefined ? fragment : document;
+  redLinkAnchorsInContent(content).forEach(function (redLink) {
+    var span = spanTemplate.cloneNode(false);
+    configureRedLinkTemplate(span, redLink);
+    replaceAnchorWithSpan(redLink, span);
+  });
+};
+
+var RedLinks = {
+  hideRedLinks: hideRedLinks,
+  test: {
+    configureRedLinkTemplate: configureRedLinkTemplate,
+    redLinkAnchorsInContent: redLinkAnchorsInContent,
+    newRedLinkTemplate: newRedLinkTemplate,
+    replaceAnchorWithSpan: replaceAnchorWithSpan
+  }
+};
+
+/**
+ * To widen an image element a css class called 'wideImageOverride' is applied 
to the image element,
+ * however, ancestors of the image element can prevent the widening from 
taking effect. This method
+ * makes minimal adjustments to ancestors of the image element being widened 
so the image widening
+ * can take effect.
+ * @param  {!HTMLElement} el Element whose ancestors will be widened
+ * @return {void}
+ */
+var widenAncestors = function widenAncestors(el) {
+  for (var parentElement = el.parentElement; parentElement && 
!parentElement.classList.contains('content_block'); parentElement = 
parentElement.parentElement) {
+    if (parentElement.style.width) {
+      parentElement.style.width = '100%';
+    }
+    if (parentElement.style.maxWidth) {
+      parentElement.style.maxWidth = '100%';
+    }
+    if (parentElement.style.float) {
+      parentElement.style.float = 'none';
+    }
+  }
+};
+
+/**
+ * Some images should not be widened. This method makes that determination.
+ * @param  {!HTMLElement} image   The image in question
+ * @return {boolean}              Whether 'image' should be widened
+ */
+var shouldWidenImage = function shouldWidenImage(image) {
+  // Images within a "<div class='noresize'>...</div>" should not be widened.
+  // Example exhibiting links overlaying such an image:
+  //   'enwiki > Counties of England > Scope and structure > Local government'
+  if (elementUtilities.findClosestAncestor(image, "[class*='noresize']")) {
+    return false;
+  }
+
+  // Side-by-side images should not be widened. Often their captions mention 
'left' and 'right', so
+  // we don't want to widen these as doing so would stack them vertically.
+  // Examples exhibiting side-by-side images:
+  //    'enwiki > Cold Comfort (Inside No. 9) > Casting'
+  //    'enwiki > Vincent van Gogh > Letters'
+  if (elementUtilities.findClosestAncestor(image, "div[class*='tsingle']")) {
+    return false;
+  }
+
+  // Imagemaps, which expect images to be specific sizes, should not be 
widened.
+  // Examples can be found on 'enwiki > Kingdom (biology)':
+  //    - first non lead image is an image map
+  //    - 'Three domains of life > Phylogenetic Tree of Life' image is an 
image map
+  if (image.hasAttribute('usemap')) {
+    return false;
+  }
+
+  // Images in tables should not be widened - doing so can horribly mess up 
table layout.
+  if (elementUtilities.isNestedInTable(image)) {
+    return false;
+  }
+
+  return true;
+};
+
+/**
+ * Removes barriers to images widening taking effect.
+ * @param  {!HTMLElement} image   The image in question
+ * @return {void}
+ */
+var makeRoomForImageWidening = function makeRoomForImageWidening(image) {
+  widenAncestors(image);
+
+  // Remove width and height attributes so wideImageOverride width percentages 
can take effect.
+  image.removeAttribute('width');
+  image.removeAttribute('height');
+};
+
+/**
+ * Widens the image.
+ * @param  {!HTMLElement} image   The image in question
+ * @return {void}
+ */
+var widenImage = function widenImage(image) {
+  makeRoomForImageWidening(image);
+  image.classList.add('wideImageOverride');
+};
+
+/**
+ * Widens an image if the image is found to be fit for widening.
+ * @param  {!HTMLElement} image   The image in question
+ * @return {boolean}              Whether or not 'image' was widened
+ */
+var maybeWidenImage = function maybeWidenImage(image) {
+  if (shouldWidenImage(image)) {
+    widenImage(image);
+    return true;
+  }
+  return false;
+};
+
+var WidenImage = {
+  maybeWidenImage: maybeWidenImage,
+  test: {
+    shouldWidenImage: shouldWidenImage,
+    widenAncestors: widenAncestors
+  }
+};
+
+var pagelib$1 = {
+  CollapseTable: CollapseTable,
+  LazyLoadTransform: LazyLoadTransform,
+  LazyLoadTransformer: _class$1,
+  RedLinks: RedLinks,
+  WidenImage: WidenImage,
+  test: {
+    ElementUtilities: elementUtilities, EventThrottle: _class, Rectangle: 
_class$2, Throttle: Throttle
+  }
+};
+
+// This file exists for CSS packaging only. It imports the override CSS
+// JavaScript index file, which also exists only for packaging, as well as the
+// real JavaScript, transform/index, it simply re-exports.
+
+return pagelib$1;
+
+})));
+
+
+},{}],2:[function(require,module,exports){
 var bridge = require('./bridge');
 var util = require('./utilities');
 
@@ -91,7 +1315,7 @@
 
 module.exports = new ActionsHandler();
 
-},{"./bridge":2,"./utilities":24}],2:[function(require,module,exports){
+},{"./bridge":3,"./utilities":25}],3:[function(require,module,exports){
 function Bridge() {
 }
 
@@ -129,11 +1353,11 @@
 window.onload = function() {
     module.exports.sendMessage( "DOMLoaded", {} );
 };
-},{}],3:[function(require,module,exports){
+},{}],4:[function(require,module,exports){
 module.exports = {
     DARK_STYLE_FILENAME: "file:///android_asset/dark.css"
 }
-},{}],4:[function(require,module,exports){
+},{}],5:[function(require,module,exports){
 var bridge = require("./bridge");
 var constant = require("./constant");
 var loader = require("./loader");
@@ -217,7 +1441,7 @@
     setImageBackgroundsForDarkMode: setImageBackgroundsForDarkMode
 };
 
-},{"./bridge":2,"./constant":3,"./loader":8,"./utilities":24}],5:[function(require,module,exports){
+},{"./bridge":3,"./constant":4,"./loader":9,"./utilities":25}],6:[function(require,module,exports){
 var transformer = require('./transformer');
 
 transformer.register( 'displayDisambigLink', function( content ) {
@@ -235,7 +1459,7 @@
     return content;
 } );
 
-},{"./transformer":14}],6:[function(require,module,exports){
+},{"./transformer":15}],7:[function(require,module,exports){
 var actions = require('./actions');
 var bridge = require('./bridge');
 
@@ -244,7 +1468,7 @@
     event.preventDefault();
 } );
 
-},{"./actions":1,"./bridge":2}],7:[function(require,module,exports){
+},{"./actions":2,"./bridge":3}],8:[function(require,module,exports){
 var transformer = require('./transformer');
 
 transformer.register( 'displayIssuesLink', function( content ) {
@@ -264,7 +1488,7 @@
     return content;
 } );
 
-},{"./transformer":14}],8:[function(require,module,exports){
+},{"./transformer":15}],9:[function(require,module,exports){
 function addStyleLink( href ) {
     var link = document.createElement( "link" );
     link.setAttribute( "rel", "stylesheet" );
@@ -277,7 +1501,7 @@
 module.exports = {
     addStyleLink: addStyleLink
 };
-},{}],9:[function(require,module,exports){
+},{}],10:[function(require,module,exports){
 var bridge = require( "./bridge" );
 var transformer = require("./transformer");
 
@@ -301,7 +1525,7 @@
     transformer.setDecorOffset(payload.offset);
 } );
 
-},{"./bridge":2,"./transformer":14}],10:[function(require,module,exports){
+},{"./bridge":3,"./transformer":15}],11:[function(require,module,exports){
 var bridge = require("./bridge");
 
 /*
@@ -323,7 +1547,7 @@
 module.exports = {
     addIPAonClick: addIPAonClick
 };
-},{"./bridge":2}],11:[function(require,module,exports){
+},{"./bridge":3}],12:[function(require,module,exports){
 var bridge = require("./bridge");
 
 bridge.registerListener( "displayPreviewHTML", function( payload ) {
@@ -333,7 +1557,7 @@
     content.innerHTML = payload.html;
 } );
 
-},{"./bridge":2}],12:[function(require,module,exports){
+},{"./bridge":3}],13:[function(require,module,exports){
 var bridge = require("./bridge");
 
 bridge.registerListener( "setDirectionality", function( payload ) {
@@ -349,10 +1573,13 @@
     html.classList.add( "ui-" + payload.uiDirection );
 } );
 
-},{"./bridge":2}],13:[function(require,module,exports){
+},{"./bridge":3}],14:[function(require,module,exports){
 var bridge = require("./bridge");
 var transformer = require("./transformer");
 var clickHandlerSetup = require("./onclick");
+var pagelib = require("wikimedia-page-library");
+var lazyLoadViewportDistanceMultiplier = 2; // Load images up to one screen 
ahead.
+var lazyLoadTransformer = new pagelib.LazyLoadTransformer(window, 
lazyLoadViewportDistanceMultiplier);
 
 bridge.registerListener( "clearContents", function() {
     clearContents();
@@ -538,8 +1765,11 @@
         transformer.transform( "hideTables", content ); // clickHandler
 
         if (!window.isNetworkMetered) {
+            // This transform must occur prior to the lazy image loading 
transform.
             transformer.transform( "widenImages", content ); // offsetWidth
         }
+
+        lazyLoadTransformer.transform(content);
     }
 
     return [ heading, content ];
@@ -568,6 +1798,7 @@
                 scrolledOnLoad = true;
             }
         });
+
         // do we have a section to scroll to?
         if ( typeof payload.fragment === "string" && payload.fragment.length > 
0 && payload.section.anchor === payload.fragment) {
             scrollToSection( payload.fragment );
@@ -648,7 +1879,7 @@
     bridge.sendMessage( "currentSectionResponse", { sectionID: 
getCurrentSection() } );
 } );
 
-},{"./bridge":2,"./onclick":10,"./transformer":14}],14:[function(require,module,exports){
+},{"./bridge":3,"./onclick":11,"./transformer":15,"wikimedia-page-library":1}],15:[function(require,module,exports){
 function Transformer() {
 }
 
@@ -679,7 +1910,7 @@
 };
 
 module.exports = new Transformer();
-},{}],15:[function(require,module,exports){
+},{}],16:[function(require,module,exports){
 var transformer = require("../transformer");
 var dark = require("../dark");
 
@@ -688,7 +1919,7 @@
         dark.setImageBackgroundsForDarkMode ( content );
     }
 } );
-},{"../dark":4,"../transformer":14}],16:[function(require,module,exports){
+},{"../dark":5,"../transformer":15}],17:[function(require,module,exports){
 var pagelib = require("wikimedia-page-library");
 var transformer = require("../transformer");
 
@@ -701,7 +1932,7 @@
 }
 
 transformer.register( "hideTables", function(content) {
-    pagelib.CollapseTable.collapseTables(document, content, window.pageTitle,
+    pagelib.CollapseTable.collapseTables(window, content, window.pageTitle,
         window.isMainPage, window.string_table_infobox,
         window.string_table_other, window.string_table_close,
         scrollWithDecorOffset);
@@ -711,7 +1942,7 @@
     handleTableCollapseOrExpandClick: toggleCollapseClickCallback
 };
 
-},{"../transformer":14,"wikimedia-page-library":25}],17:[function(require,module,exports){
+},{"../transformer":15,"wikimedia-page-library":1}],18:[function(require,module,exports){
 var transformer = require("../transformer");
 var collapseTables = require("./collapseTables");
 
@@ -757,7 +1988,7 @@
         bottomDiv.onclick = collapseTables.handleTableCollapseOrExpandClick;
     }
 } );
-},{"../transformer":14,"./collapseTables":16}],18:[function(require,module,exports){
+},{"../transformer":15,"./collapseTables":17}],19:[function(require,module,exports){
 var transformer = require("../../transformer");
 
 transformer.register( "anchorPopUpMediaTransforms", function( content ) {
@@ -790,7 +2021,7 @@
     }
 } );
 
-},{"../../transformer":14}],19:[function(require,module,exports){
+},{"../../transformer":15}],20:[function(require,module,exports){
 var transformer = require("../../transformer");
 var bridge = require("../../bridge");
 
@@ -839,7 +2070,7 @@
         containerSpan.onclick = ipaClickHandler;
     }
 } );
-},{"../../bridge":2,"../../transformer":14}],20:[function(require,module,exports){
+},{"../../bridge":3,"../../transformer":15}],21:[function(require,module,exports){
 var transformer = require("../../transformer");
 
 transformer.register( "hideRedLinks", function( content ) {
@@ -852,7 +2083,7 @@
         redLink.parentNode.replaceChild( replacementSpan, redLink );
     }
 } );
-},{"../../transformer":14}],21:[function(require,module,exports){
+},{"../../transformer":15}],22:[function(require,module,exports){
 var transformer = require("../../transformer");
 
 // Move the first non-empty paragraph (and related elements) to the top of the 
section.
@@ -936,7 +2167,7 @@
     }
 }
 
-},{"../../transformer":14}],22:[function(require,module,exports){
+},{"../../transformer":15}],23:[function(require,module,exports){
 var transformer = require("../transformer");
 
 transformer.register( "setDivWidth", function( content ) {
@@ -954,7 +2185,7 @@
         }
     }
 } );
-},{"../transformer":14}],23:[function(require,module,exports){
+},{"../transformer":15}],24:[function(require,module,exports){
 var maybeWidenImage = 
require('wikimedia-page-library').WidenImage.maybeWidenImage;
 var transformer = require("../transformer");
 var utilities = require("../utilities");
@@ -1013,7 +2244,7 @@
     }
 } );
 
-},{"../transformer":14,"../utilities":24,"wikimedia-page-library":25}],24:[function(require,module,exports){
+},{"../transformer":15,"../utilities":25,"wikimedia-page-library":1}],25:[function(require,module,exports){
 
 function hasAncestor( el, tagName ) {
     if (el !== null && el.tagName === tagName) {
@@ -1108,437 +2339,4 @@
     firstAncestorWithMultipleChildren: firstAncestorWithMultipleChildren
 };
 
-},{}],25:[function(require,module,exports){
-'use strict';
-
-// This file exists for CSS packaging only. It imports the CSS which is to be
-// packaged in the override CSS build product.
-
-// todo: delete Empty.css when other overrides exist
-
-/**
- * Polyfill function that tells whether a given element matches a selector.
- * @param {!Element} el Element
- * @param {!string} selector Selector to look for
- * @return {!boolean} Whether the element matches the selector
- */
-var matchesSelectorCompat = function matchesSelectorCompat(el, selector) {
-  if (el.matches) {
-    return el.matches(selector);
-  }
-  if (el.matchesSelector) {
-    return el.matchesSelector(selector);
-  }
-  if (el.webkitMatchesSelector) {
-    return el.webkitMatchesSelector(selector);
-  }
-  return false;
-};
-
-/**
- * Returns closest ancestor of element which matches selector.
- * Similar to 'closest' methods as seen here:
- *  https://api.jquery.com/closest/
- *  https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
- * @param  {!Element} el        Element
- * @param  {!string} selector   Selector to look for in ancestors of 'el'
- * @return {?HTMLElement}       Closest ancestor of 'el' matching 'selector'
- */
-var findClosestAncestor = function findClosestAncestor(el, selector) {
-  var parentElement = void 0;
-  for (parentElement = el.parentElement; parentElement && 
!matchesSelectorCompat(parentElement, selector); parentElement = 
parentElement.parentElement) {
-    // Intentionally empty.
-  }
-  return parentElement;
-};
-
-/**
- * Determines if element has a table ancestor.
- * @param  {!Element}  el   Element
- * @return {boolean}        Whether table ancestor of 'el' is found
- */
-var isNestedInTable = function isNestedInTable(el) {
-  return Boolean(findClosestAncestor(el, 'table'));
-};
-
-var elementUtilities = {
-  matchesSelectorCompat: matchesSelectorCompat,
-  findClosestAncestor: findClosestAncestor,
-  isNestedInTable: isNestedInTable
-};
-
-/**
- * Find an array of table header (TH) contents. If there are no TH elements in
- * the table or the header's link matches pageTitle, an empty array is 
returned.
- * @param {!Element} element
- * @param {?string} pageTitle Unencoded page title; if this title matches the
- *                            contents of the header exactly, it will be 
omitted.
- * @return {!Array<string>}
- */
-var getTableHeader = function getTableHeader(element, pageTitle) {
-  var thArray = [];
-
-  if (!element.children) {
-    return thArray;
-  }
-
-  for (var i = 0; i < element.children.length; i++) {
-    var el = element.children[i];
-
-    if (el.tagName === 'TH') {
-      // ok, we have a TH element!
-      // However, if it contains more than two links, then ignore it, because
-      // it will probably appear weird when rendered as plain text.
-      var aNodes = el.querySelectorAll('a');
-      // todo: these conditionals are very confusing. Rewrite by extracting a
-      //       method or simplify.
-      if (aNodes.length < 3) {
-        // todo: remove nonstandard Element.innerText usage
-        // Also ignore it if it's identical to the page title.
-        if ((el.innerText && el.innerText.length || el.textContent.length) > 0 
&& el.innerText !== pageTitle && el.textContent !== pageTitle && el.innerHTML 
!== pageTitle) {
-          thArray.push(el.innerText || el.textContent);
-        }
-      }
-    }
-
-    // if it's a table within a table, don't worry about it
-    if (el.tagName === 'TABLE') {
-      continue;
-    }
-
-    // todo: why do we need to recurse?
-    // recurse into children of this element
-    var ret = getTableHeader(el, pageTitle);
-
-    // did we get a list of TH from this child?
-    if (ret.length > 0) {
-      thArray = thArray.concat(ret);
-    }
-  }
-
-  return thArray;
-};
-
-/** Ex: toggleCollapseClickCallback.bind(el, (container) => {
-          window.scrollTo(0, container.offsetTop - 
transformer.getDecorOffset())
-        })
-    @this HTMLElement
-    @param footerDivClickCallback {?(!HTMLElement) => void}
-    @return {void} */
-var toggleCollapseClickCallback = function 
toggleCollapseClickCallback(footerDivClickCallback) {
-  var container = this.parentNode;
-  var header = container.children[0];
-  var table = container.children[1];
-  var footer = container.children[2];
-  var caption = header.querySelector('.app_table_collapsed_caption');
-  if (table.style.display !== 'none') {
-    table.style.display = 'none';
-    header.classList.remove('app_table_collapse_close'); // todo: use 
app_table_collapsed_collapsed
-    header.classList.remove('app_table_collapse_icon'); // todo: use 
app_table_collapsed_icon
-    header.classList.add('app_table_collapsed_open'); // todo: use 
app_table_collapsed_expanded
-    if (caption) {
-      caption.style.visibility = 'visible';
-    }
-    footer.style.display = 'none';
-    // if they clicked the bottom div, then scroll back up to the top of the 
table.
-    if (this === footer && footerDivClickCallback) {
-      footerDivClickCallback(container);
-    }
-  } else {
-    table.style.display = 'block';
-    header.classList.remove('app_table_collapsed_open'); // todo: use 
app_table_collapsed_expanded
-    header.classList.add('app_table_collapse_close'); // todo: use 
app_table_collapsed_collapsed
-    header.classList.add('app_table_collapse_icon'); // todo: use 
app_table_collapsed_icon
-    if (caption) {
-      caption.style.visibility = 'hidden';
-    }
-    footer.style.display = 'block';
-  }
-};
-
-/**
- * @param {!HTMLElement} table
- * @return {!boolean} true if table should be collapsed, false otherwise.
- */
-var shouldTableBeCollapsed = function shouldTableBeCollapsed(table) {
-  var classBlacklist = ['navbox', 'vertical-navbox', 'navbox-inner', 
'metadata', 'mbox-small'];
-  var blacklistIntersects = classBlacklist.some(function (clazz) {
-    return table.classList.contains(clazz);
-  });
-  return table.style.display !== 'none' && !blacklistIntersects;
-};
-
-/**
- * @param {!Element} element
- * @return {!boolean} true if element is an infobox, false otherwise.
- */
-var isInfobox = function isInfobox(element) {
-  return element.classList.contains('infobox');
-};
-
-/**
- * @param {!Document} document
- * @param {?string} content HTML string.
- * @return {!HTMLDivElement}
- */
-var newCollapsedHeaderDiv = function newCollapsedHeaderDiv(document, content) {
-  var div = document.createElement('div');
-  div.classList.add('app_table_collapsed_container');
-  div.classList.add('app_table_collapsed_open');
-  div.innerHTML = content || '';
-  return div;
-};
-
-/**
- * @param {!Document} document
- * @param {?string} content HTML string.
- * @return {!HTMLDivElement}
- */
-var newCollapsedFooterDiv = function newCollapsedFooterDiv(document, content) {
-  var div = document.createElement('div');
-  div.classList.add('app_table_collapsed_bottom');
-  div.classList.add('app_table_collapse_icon'); // todo: use collapsed 
everywhere
-  div.innerHTML = content || '';
-  return div;
-};
-
-/**
- * @param {!string} title
- * @param {!string[]} headerText
- * @return {!string} HTML string.
- */
-var newCaption = function newCaption(title, headerText) {
-  var caption = '<strong>' + title + '</strong>';
-
-  caption += '<span class=app_span_collapse_text>';
-  if (headerText.length > 0) {
-    caption += ': ' + headerText[0];
-  }
-  if (headerText.length > 1) {
-    caption += ', ' + headerText[1];
-  }
-  if (headerText.length > 0) {
-    caption += ' …';
-  }
-  caption += '</span>';
-
-  return caption;
-};
-
-/**
- * @param {!Document} document
- * @param {!Element} content
- * @param {?string} pageTitle
- * @param {?boolean} isMainPage
- * @param {?string} infoboxTitle
- * @param {?string} otherTitle
- * @param {?string} footerTitle
- * @return {void}
- */
-var collapseTables = function collapseTables(document, content, pageTitle, 
isMainPage, infoboxTitle, otherTitle, footerTitle, footerDivClickCallback) {
-  if (isMainPage) {
-    return;
-  }
-
-  var tables = content.querySelectorAll('table');
-  for (var i = 0; i < tables.length; ++i) {
-    var table = tables[i];
-
-    if (elementUtilities.findClosestAncestor(table, '.app_table_container') || 
!shouldTableBeCollapsed(table)) {
-      continue;
-    }
-
-    // todo: this is actually an array
-    var headerText = getTableHeader(table, pageTitle);
-    if (!headerText.length && !isInfobox(table)) {
-      continue;
-    }
-    var caption = newCaption(isInfobox(table) ? infoboxTitle : otherTitle, 
headerText);
-
-    // create the container div that will contain both the original table
-    // and the collapsed version.
-    var containerDiv = document.createElement('div');
-    containerDiv.className = 'app_table_container';
-    table.parentNode.insertBefore(containerDiv, table);
-    table.parentNode.removeChild(table);
-
-    // remove top and bottom margin from the table, so that it's flush with
-    // our expand/collapse buttons
-    table.style.marginTop = '0px';
-    table.style.marginBottom = '0px';
-
-    var collapsedHeaderDiv = newCollapsedHeaderDiv(document, caption);
-    collapsedHeaderDiv.style.display = 'block';
-
-    var collapsedFooterDiv = newCollapsedFooterDiv(document, footerTitle);
-    collapsedFooterDiv.style.display = 'none';
-
-    // add our stuff to the container
-    containerDiv.appendChild(collapsedHeaderDiv);
-    containerDiv.appendChild(table);
-    containerDiv.appendChild(collapsedFooterDiv);
-
-    // set initial visibility
-    table.style.display = 'none';
-
-    // assign click handler to the collapsed divs
-    collapsedHeaderDiv.onclick = 
toggleCollapseClickCallback.bind(collapsedHeaderDiv);
-    collapsedFooterDiv.onclick = 
toggleCollapseClickCallback.bind(collapsedFooterDiv, footerDivClickCallback);
-  }
-};
-
-/**
- * If you tap a reference targeting an anchor within a collapsed table, this
- * method will expand the references section. The client can then scroll to the
- * references section.
- *
- * The first reference (an "[A]") in the "enwiki > Airplane" article from ~June
- * 2016 exhibits this issue. (You can copy wikitext from this revision into a
- * test wiki page for testing.)
- * @param  {?Element} element
- * @return {void}
-*/
-var expandCollapsedTableIfItContainsElement = function 
expandCollapsedTableIfItContainsElement(element) {
-  if (element) {
-    var containerSelector = '[class*="app_table_container"]';
-    var container = elementUtilities.findClosestAncestor(element, 
containerSelector);
-    if (container) {
-      var collapsedDiv = container.firstElementChild;
-      if (collapsedDiv && 
collapsedDiv.classList.contains('app_table_collapsed_open')) {
-        collapsedDiv.click();
-      }
-    }
-  }
-};
-
-var CollapseTable = {
-  toggleCollapseClickCallback: toggleCollapseClickCallback,
-  collapseTables: collapseTables,
-  expandCollapsedTableIfItContainsElement: 
expandCollapsedTableIfItContainsElement,
-  test: {
-    getTableHeader: getTableHeader,
-    shouldTableBeCollapsed: shouldTableBeCollapsed,
-    isInfobox: isInfobox,
-    newCollapsedHeaderDiv: newCollapsedHeaderDiv,
-    newCollapsedFooterDiv: newCollapsedFooterDiv,
-    newCaption: newCaption
-  }
-};
-
-/**
- * To widen an image element a css class called 'wideImageOverride' is applied 
to the image element,
- * however, ancestors of the image element can prevent the widening from 
taking effect. This method
- * makes minimal adjustments to ancestors of the image element being widened 
so the image widening
- * can take effect.
- * @param  {!HTMLElement} el Element whose ancestors will be widened
- */
-var widenAncestors = function widenAncestors(el) {
-  for (var parentElement = el.parentElement; parentElement && 
!parentElement.classList.contains('content_block'); parentElement = 
parentElement.parentElement) {
-    if (parentElement.style.width) {
-      parentElement.style.width = '100%';
-    }
-    if (parentElement.style.maxWidth) {
-      parentElement.style.maxWidth = '100%';
-    }
-    if (parentElement.style.float) {
-      parentElement.style.float = 'none';
-    }
-  }
-};
-
-/**
- * Some images should not be widended. This method makes that determination.
- * @param  {!HTMLElement} image   The image in question
- * @return {boolean}              Whether 'image' should be widened
- */
-var shouldWidenImage = function shouldWidenImage(image) {
-  // Images within a "<div class='noresize'>...</div>" should not be widened.
-  // Example exhibiting links overlaying such an image:
-  //   'enwiki > Counties of England > Scope and structure > Local government'
-  if (elementUtilities.findClosestAncestor(image, "[class*='noresize']")) {
-    return false;
-  }
-
-  // Side-by-side images should not be widened. Often their captions mention 
'left' and 'right', so
-  // we don't want to widen these as doing so would stack them vertically.
-  // Examples exhibiting side-by-side images:
-  //    'enwiki > Cold Comfort (Inside No. 9) > Casting'
-  //    'enwiki > Vincent van Gogh > Letters'
-  if (elementUtilities.findClosestAncestor(image, "div[class*='tsingle']")) {
-    return false;
-  }
-
-  // Imagemaps, which expect images to be specific sizes, should not be 
widened.
-  // Examples can be found on 'enwiki > Kingdom (biology)':
-  //    - first non lead image is an image map
-  //    - 'Three domains of life > Phylogenetic Tree of Life' image is an 
image map
-  if (image.hasAttribute('usemap')) {
-    return false;
-  }
-
-  // Images in tables should not be widened - doing so can horribly mess up 
table layout.
-  if (elementUtilities.isNestedInTable(image)) {
-    return false;
-  }
-
-  return true;
-};
-
-/**
- * Removes barriers to images widening taking effect.
- * @param  {!HTMLElement} image   The image in question
- */
-var makeRoomForImageWidening = function makeRoomForImageWidening(image) {
-  widenAncestors(image);
-
-  // Remove width and height attributes so wideImageOverride width percentages 
can take effect.
-  image.removeAttribute('width');
-  image.removeAttribute('height');
-};
-
-/**
- * Widens the image.
- * @param  {!HTMLElement} image   The image in question
- */
-var widenImage = function widenImage(image) {
-  makeRoomForImageWidening(image);
-  image.classList.add('wideImageOverride');
-};
-
-/**
- * Widens an image if the image is found to be fit for widening.
- * @param  {!HTMLElement} image   The image in question
- * @return {boolean}              Whether or not 'image' was widened
- */
-var maybeWidenImage = function maybeWidenImage(image) {
-  if (shouldWidenImage(image)) {
-    widenImage(image);
-    return true;
-  }
-  return false;
-};
-
-var WidenImage = {
-  maybeWidenImage: maybeWidenImage,
-  test: {
-    shouldWidenImage: shouldWidenImage,
-    widenAncestors: widenAncestors
-  }
-};
-
-var pagelib$1 = {
-  CollapseTable: CollapseTable,
-  WidenImage: WidenImage,
-  test: {
-    ElementUtilities: elementUtilities
-  }
-};
-
-// This file exists for CSS packaging only. It imports the override CSS
-// JavaScript index file, which also exists only for packaging, as well as the
-// real JavaScript, transform/index, it simply re-exports.
-
-module.exports = pagelib$1;
-
-
-},{}]},{},[2,9,24,14,15,16,17,22,23,18,19,20,21,1,5,6,7,8,4,11,12,13]);
+},{}]},{},[3,10,25,15,16,17,18,23,24,19,20,21,22,2,6,7,8,9,5,12,13,14]);
diff --git a/app/src/main/assets/styles.css b/app/src/main/assets/styles.css
index 66ffae8..4bcdf66 100644
--- a/app/src/main/assets/styles.css
+++ b/app/src/main/assets/styles.css
@@ -1405,7 +1405,8 @@
   -webkit-margin-end: 0 !important;
 }
 .content figure img,
-.content figure video {
+.content figure video,
+.content figure .pagelib-lazy-load-placeholder {
   margin: 0.6em auto 0.6em auto;
   display: block;
   clear: both;
diff --git a/app/src/main/assets/wikimedia-page-library.css 
b/app/src/main/assets/wikimedia-page-library.css
index d6b154c..ef93398 100644
--- a/app/src/main/assets/wikimedia-page-library.css
+++ b/app/src/main/assets/wikimedia-page-library.css
@@ -138,6 +138,79 @@
 .app_table_collapse_icon {
   box-sizing: border-box;
 }
+:root {
+  /* 
https://phabricator.wikimedia.org/source/wikimedia-ui-base/browse/master/wikimedia-ui-base.css
 */
+  --wmui-color-base80: #eaecf0;
+
+  --pagelib-lazy-load-placeholder-background-color: var(--wmui-color-base80);
+  --pagelib-lazy-load-placeholder-animation-duration: .3s;
+}
+
+/* Transform lifecycle:
+  - Original:
+    <img src=/ width=1 height=2>
+
+  - Transformed (replace image with span and preserve image attributes as 
data-* attributes):
+    <span class='pagelib-lazy-load-placeholder 
pagelib-lazy-load-placeholder-pending'
+      style='width: 1px; height: 2px;' data-src=/ data-width=1 
data-height=2></span>
+
+  - Loading (replace pending class with loading):
+    <span class='pagelib-lazy-load-placeholder 
pagelib-lazy-load-placeholder-loading'
+      style='width: 1px; height: 2px;' data-src=/ data-width=1 
data-height=2></span>
+
+  - Loaded (append image and replace loading class with loaded):
+    <span class='pagelib-lazy-load-placeholder 
pagelib-lazy-load-placeholder-loaded'
+      style='width: 1px; height: 2px;' data-src=/ data-width=1 data-height=2>
+      <img src=/ width=1 height=2>
+    </span>
+*/
+
+/* Placeholders are persistent members of this class. This class must match
+   LazyLoadTransform.PLACEHOLDER_CLASS. Additionally, width and height 
attributes as specified by a
+   replaced image are set as inline styles on the placeholder. */
+.pagelib-lazy-load-placeholder {
+  /* In order to avoid reflows placeholder needs to be block, or 
inline-block+overflow:hidden given
+     it is nested inside an inline <a>.*/
+  display: block;
+
+  /* Placeholders are just blank boxes. */
+  background-color: var(--pagelib-lazy-load-placeholder-background-color);
+}
+
+/* Placeholders that have not started downloading are temporary members of 
this class. This class
+   must match LazyLoadTransform.PLACHOLDER_PENDING_CLASS. */
+.pagelib-lazy-load-placeholder-pending {}
+
+/* Placeholders that have started downloading are temporary members of this 
class. This class must
+   match LazyLoadTransform.PLACHOLDER_LOADING_CLASS. */
+.pagelib-lazy-load-placeholder-loading {}
+
+/* Placeholders that have finished downloading are permanent members of this 
class. This class must
+   match LazyLoadTransform.PLACHOLDER_LOADED_CLASS. */
+.pagelib-lazy-load-placeholder-loaded {
+  /* When the image has loaded, transition the background color for a fade-out 
effect, allowing the
+     page background to show for a translucent image. */
+  animation: pagelib-lazy-load-placeholder-fade-out 
var(--pagelib-lazy-load-placeholder-animation-duration) ease-in;
+  background-color: transparent;
+}
+
+.pagelib-lazy-load-placeholder-loaded img {
+  /* When the image has loaded, transition the image in for a fade-in effect. 
*/
+  animation: pagelib-lazy-load-image-fade-in 
var(--pagelib-lazy-load-placeholder-animation-duration) ease-in;
+  opacity: 1;
+}
+
+@keyframes pagelib-lazy-load-image-fade-in {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+/* The lazily loaded image is a child. Animate the background color to 
transparency instead of the
+   opacity which would affect the child. */
+@keyframes pagelib-lazy-load-placeholder-fade-out {
+  from { background-color: 
var(--pagelib-lazy-load-placeholder-background-color); }
+  to { background-color: transparent; }
+}
 .wideImageOverride {
   /* Center images. */
   display: block;
diff --git a/www/js/sections.js b/www/js/sections.js
index a95d5d2..c299903 100644
--- a/www/js/sections.js
+++ b/www/js/sections.js
@@ -1,6 +1,9 @@
 var bridge = require("./bridge");
 var transformer = require("./transformer");
 var clickHandlerSetup = require("./onclick");
+var pagelib = require("wikimedia-page-library");
+var lazyLoadViewportDistanceMultiplier = 2; // Load images up to one screen 
ahead.
+var lazyLoadTransformer = new pagelib.LazyLoadTransformer(window, 
lazyLoadViewportDistanceMultiplier);
 
 bridge.registerListener( "clearContents", function() {
     clearContents();
@@ -186,8 +189,11 @@
         transformer.transform( "hideTables", content ); // clickHandler
 
         if (!window.isNetworkMetered) {
+            // This transform must occur prior to the lazy image loading 
transform.
             transformer.transform( "widenImages", content ); // offsetWidth
         }
+
+        lazyLoadTransformer.transform(content);
     }
 
     return [ heading, content ];
@@ -216,6 +222,7 @@
                 scrolledOnLoad = true;
             }
         });
+
         // do we have a section to scroll to?
         if ( typeof payload.fragment === "string" && payload.fragment.length > 
0 && payload.section.anchor === payload.fragment) {
             scrollToSection( payload.fragment );
diff --git a/www/js/transforms/collapseTables.js 
b/www/js/transforms/collapseTables.js
index c94001f..4908e36 100644
--- a/www/js/transforms/collapseTables.js
+++ b/www/js/transforms/collapseTables.js
@@ -10,7 +10,7 @@
 }
 
 transformer.register( "hideTables", function(content) {
-    pagelib.CollapseTable.collapseTables(document, content, window.pageTitle,
+    pagelib.CollapseTable.collapseTables(window, content, window.pageTitle,
         window.isMainPage, window.string_table_infobox,
         window.string_table_other, window.string_table_close,
         scrollWithDecorOffset);

-- 
To view, visit https://gerrit.wikimedia.org/r/362128
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: Icf623c6f1e77f52db39a11e1c5de4748850a7100
Gerrit-PatchSet: 1
Gerrit-Project: apps/android/wikipedia
Gerrit-Branch: master
Gerrit-Owner: Niedzielski <sniedziel...@wikimedia.org>
Gerrit-Reviewer: Sniedzielski <sniedziel...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to