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