diff --git a/service-worker.js b/service-worker.js index 90c2b4c7..a41bfbc3 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -/** +/** * service-worker.js : Service Worker implementation, * in order to capture the HTTP requests made by an article, and respond with the * corresponding content, coming from the archive @@ -23,9 +23,14 @@ */ 'use strict'; +/** + * A global Boolean that governs whether images are displayed + * app.js can alter this variable via messaging + */ +var imageDisplay; + self.addEventListener('install', function(event) { event.waitUntil(self.skipWaiting()); - console.log("ServiceWorker installed"); }); self.addEventListener('activate', function(event) { @@ -33,7 +38,6 @@ self.addEventListener('activate', function(event) { // without the need to reload the page. // See https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim event.waitUntil(self.clients.claim()); - console.log("ServiceWorker activated"); }); var regexpRemoveUrlParameters = new RegExp(/([^?#]+)[?#].*$/); @@ -53,85 +57,54 @@ var regexpRemoveUrlParameters = new RegExp(/([^?#]+)[?#].*$/); function removeUrlParameters(url) { return url.replace(regexpRemoveUrlParameters, "$1"); } - -console.log("ServiceWorker startup"); var outgoingMessagePort = null; var fetchCaptureEnabled = false; self.addEventListener('fetch', fetchEventListener); -console.log('fetchEventListener set'); self.addEventListener('message', function (event) { if (event.data.action === 'init') { - console.log('Init message received', event.data); + // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener outgoingMessagePort = event.ports[0]; - console.log('outgoingMessagePort initialized', outgoingMessagePort); fetchCaptureEnabled = true; - console.log('fetchEventListener enabled'); } if (event.data.action === 'disable') { - console.log('Disable message received'); + // On 'disable' message, we delete the outgoingMessagePort and disable the fetchEventListener outgoingMessagePort = null; - console.log('outgoingMessagePort deleted'); fetchCaptureEnabled = false; - console.log('fetchEventListener disabled'); } }); -// TODO : this way to recognize content types is temporary -// It must be replaced by reading the actual MIME-Type from the backend -var regexpJPEG = new RegExp(/\.jpe?g$/i); -var regexpPNG = new RegExp(/\.png$/i); -var regexpJS = new RegExp(/\.js/i); -var regexpCSS = new RegExp(/\.css$/i); -var regexpSVG = new RegExp(/\.svg$/i); - -// Pattern for ZIM file namespace - see http://www.openzim.org/wiki/ZIM_file_format#Namespaces +// Pattern for ZIM file namespace - see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces var regexpZIMUrlWithNamespace = new RegExp(/(?:^|\/)([-ABIJMUVWX])\/(.+)/); function fetchEventListener(event) { if (fetchCaptureEnabled) { - console.log('ServiceWorker handling fetch event for : ' + event.request.url); - if (regexpZIMUrlWithNamespace.test(event.request.url)) { + // The ServiceWorker will handle this request - console.log('Asking app.js for a content', event.request.url); + // If the user has disabled the display of images, and the browser wants an image, respond with empty SVG + // A URL with "?kiwix-display" query string acts as a passthrough so that the regex will not match and + // the image will be fetched by app.js + // DEV: If you need to hide more image types, add them to regex below and also edit equivalent regex in app.js + if (imageDisplay !== 'all' && /(^|\/)[IJ]\/.*\.(jpe?g|png|svg|gif)($|[?#])(?!kiwix-display)/i.test(event.request.url)) { + var svgResponse; + if (imageDisplay === 'manual') + svgResponse = ""; + else + svgResponse = ""; + event.respondWith(new Response(svgResponse, { headers: { 'Content-Type': 'image/svg+xml' } })); + return; + } + + // Let's ask app.js for that content event.respondWith(new Promise(function(resolve, reject) { var nameSpace; var title; var titleWithNameSpace; - var contentType; var regexpResult = regexpZIMUrlWithNamespace.exec(event.request.url); - nameSpace = regexpResult[1]; - title = regexpResult[2]; - - // The namespace defines the type of content. See http://www.openzim.org/wiki/ZIM_file_format#Namespaces - // TODO : read the contentType from the ZIM file instead of hard-coding it here - if (nameSpace === 'A') { - console.log("It's an article : " + title); - contentType = 'text/html'; - } - else if (nameSpace === 'I' || nameSpace === 'J') { - console.log("It's an image : " + title); - if (regexpJPEG.test(title)) { - contentType = 'image/jpeg'; - } - else if (regexpPNG.test(title)) { - contentType = 'image/png'; - } - else if (regexpSVG.test(title)) { - contentType = 'image/svg+xml'; - } - } - else if (nameSpace === '-') { - console.log("It's a layout dependency : " + title); - if (regexpJS.test(title)) { - contentType = 'text/javascript'; - } - else if (regexpCSS.test(title)) { - contentType = 'text/css'; - } - } + nameSpace = regexpResult[1]; + title = regexpResult[2]; // We need to remove the potential parameters in the URL title = removeUrlParameters(decodeURIComponent(title)); @@ -142,31 +115,44 @@ function fetchEventListener(event) { var messageChannel = new MessageChannel(); messageChannel.port1.onmessage = function(event) { if (event.data.action === 'giveContent') { - console.log('content message received for ' + titleWithNameSpace, event.data); + // Content received from app.js + var contentLength = event.data.content ? event.data.content.byteLength : null; + var contentType = event.data.mimetype; + // Set the imageDisplay variable if it has been sent in the event data + imageDisplay = typeof event.data.imageDisplay !== 'undefined' ? + event.data.imageDisplay : imageDisplay; + var headers = new Headers (); + if (contentLength) headers.set('Content-Length', contentLength); + if (contentType) headers.set('Content-Type', contentType); + // Test if the content is a video or audio file + // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") + // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp + if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { + // In case of a video (at least), Chrome and Edge need these HTTP headers else seeking doesn't work + // (even if we always send all the video content, not the requested range, until the backend supports it) + headers.set('Accept-Ranges', 'bytes'); + headers.set('Content-Range', 'bytes 0-' + (contentLength-1) + '/' + contentLength); + } var responseInit = { status: 200, statusText: 'OK', - headers: { - 'Content-Type': contentType - } + headers: headers }; var httpResponse = new Response(event.data.content, responseInit); - console.log('ServiceWorker responding to the HTTP request for ' + titleWithNameSpace + ' (size=' + event.data.content.length + ' octets)' , httpResponse); + // Let's send the content back from the ServiceWorker resolve(httpResponse); } else if (event.data.action === 'sendRedirect') { resolve(Response.redirect(event.data.redirectUrl)); } else { - console.log('Invalid message received from app.js for ' + titleWithNameSpace, event.data); + console.error('Invalid message received from app.js for ' + titleWithNameSpace, event.data); reject(event.data); } }; - console.log('Eventlistener added to listen for an answer to ' + titleWithNameSpace); outgoingMessagePort.postMessage({'action': 'askForContent', 'title': titleWithNameSpace}, [messageChannel.port2]); - console.log('Message sent to app.js through outgoingMessagePort'); })); } // If event.respondWith() isn't called because this wasn't a request that we want to handle, diff --git a/www/css/app.css b/www/css/app.css index d06499d1..0392ef09 100644 --- a/www/css/app.css +++ b/www/css/app.css @@ -97,7 +97,7 @@ div:not(.panel-success, .alert-message) { background: lightblue; } -#articleList a:hover, #articleList a.hover { +#articleList a:hover, #articleList a.hover, .backgroundLightBlue { background: lightblue; } diff --git a/www/index.html b/www/index.html index 437be675..3eb671b8 100644 --- a/www/index.html +++ b/www/index.html @@ -630,18 +630,23 @@
-
Content injection mode: do not touch unless you know what you're doing!
+
Content injection mode
+
+
+
+
API Status
+
diff --git a/www/js/app.js b/www/js/app.js index 99c7fc0d..789f1ff4 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -26,14 +26,22 @@ // This uses require.js to structure javascript: // http://requirejs.org/docs/api.html#define -define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module', 'transformStyles', 'kiwixServe'], - function ($, zimArchiveLoader, util, uiUtil, cookies, q, module, transformStyles, kiwixServe) { +define(['jquery', 'zimArchiveLoader', 'uiUtil', 'images', 'cookies', 'abstractFilesystemAccess', 'q', 'transformStyles', 'kiwixServe'], + function ($, zimArchiveLoader, uiUtil, images, cookies, abstractFilesystemAccess, q, transformStyles, kiwixServe) { /** * Maximum number of articles to display in a search * @type Integer */ var MAX_SEARCH_RESULT_SIZE = params.results; //This value is controlled in init.js, as are all parameters + /** + * The delay (in milliseconds) between two "keepalive" messages + * sent to the ServiceWorker (so that it is not stopped by + * the browser, and keeps the MessageChannel to communicate + * with the application) + * @type Integer + */ + var DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER = 30000; /** * @type ZIMArchive @@ -1117,11 +1125,15 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' * Displays or refreshes the API status shown to the user */ function refreshAPIStatus() { + var apiStatusPanel = document.getElementById('apiStatusDiv'); + apiStatusPanel.classList.remove('panel-success', 'panel-warning'); + var apiPanelClass = 'panel-success'; if (isMessageChannelAvailable()) { $('#messageChannelStatus').html("MessageChannel API available"); $('#messageChannelStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiAvailable"); } else { + apiPanelClass = 'panel-warning'; $('#messageChannelStatus').html("MessageChannel API unavailable"); $('#messageChannelStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiUnavailable"); @@ -1132,15 +1144,18 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' $('#serviceWorkerStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiAvailable"); } else { + apiPanelClass = 'panel-warning'; $('#serviceWorkerStatus').html("ServiceWorker API available, but not registered"); $('#serviceWorkerStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiUnavailable"); } } else { + apiPanelClass = 'panel-warning'; $('#serviceWorkerStatus').html("ServiceWorker API unavailable"); $('#serviceWorkerStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiUnavailable"); } + apiStatusPanel.classList.add(apiPanelClass); } var contentInjectionMode; @@ -1160,7 +1175,6 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' // Send the init message to the ServiceWorker, with this MessageChannel as a parameter navigator.serviceWorker.controller.postMessage({'action': 'init'}, [tmpMessageChannel.port2]); messageChannel = tmpMessageChannel; - console.log("init message sent to ServiceWorker"); // Schedule to do it again regularly to keep the 2-way communication alive. // See https://github.com/kiwix/kiwix-js/issues/145 to understand why clearTimeout(keepAliveServiceWorkerHandle); @@ -1201,7 +1215,7 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' if (!isServiceWorkerReady()) { $('#serviceWorkerStatus').html("ServiceWorker API available : trying to register it..."); navigator.serviceWorker.register('../service-worker.js').then(function (reg) { - console.log('serviceWorker registered', reg); + // The ServiceWorker is registered serviceWorkerRegistration = reg; refreshAPIStatus(); @@ -1210,6 +1224,8 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' var serviceWorker = reg.installing || reg.waiting || reg.active; serviceWorker.addEventListener('statechange', function (statechangeevent) { if (statechangeevent.target.state === 'activated') { + // Remove any jQuery hooks from a previous jQuery session + $('#articleContent').contents().remove(); // Create the MessageChannel // and send the 'init' message to the ServiceWorker initOrKeepAliveServiceWorker(); @@ -1225,45 +1241,33 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' }, function (err) { console.error('error while registering serviceWorker', err); refreshAPIStatus(); + var message = "The ServiceWorker could not be properly registered. Switching back to jQuery mode. Error message : " + err; + var protocol = window.location.protocol; + if (protocol === 'moz-extension:') { + message += "\n\nYou seem to be using kiwix-js through a Firefox extension : ServiceWorkers are disabled by Mozilla in extensions."; + message += "\nPlease vote for https://bugzilla.mozilla.org/show_bug.cgi?id=1344561 so that some future Firefox versions support it"; + } + else if (protocol === 'file:') { + message += "\n\nYou seem to be opening kiwix-js with the file:// protocol. You should open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)"; + } + uiUtil.systemAlert(message); + setContentInjectionMode("jquery"); + return; }); } else { + // We need to set this variable earlier else the ServiceWorker does not get reactivated + contentInjectionMode = value; initOrKeepAliveServiceWorker(); } } $('input:radio[name=contentInjectionMode]').prop('checked', false); $('input:radio[name=contentInjectionMode]').filter('[value="' + value + '"]').prop('checked', true); contentInjectionMode = value; + images.setContentInjectionMode(contentInjectionMode); // Save the value in a cookie, so that to be able to keep it after a reload/restart cookies.setItem('lastContentInjectionMode', value, Infinity); } - /** - * If the ServiceWorker mode is selected, warn the user before activating it - * @param chosenContentInjectionMode The mode that the user has chosen - */ - function checkWarnServiceWorkerMode(chosenContentInjectionMode) { - if (chosenContentInjectionMode === 'serviceworker' && !cookies.hasItem("warnedServiceWorkerMode")) { - // The user selected the "serviceworker" mode, which is still unstable - // So let's display a warning to the user - - // If the focus is on the search field, we have to move it, - // else the keyboard hides the message - if ($("#prefix").is(":focus")) { - $("#searchArticles").focus(); - } - if (confirm("The 'Service Worker' mode is still UNSTABLE for now." + - " It happens that the application needs to be reinstalled (or the ServiceWorker manually removed)." + - " Please confirm with OK that you're ready to face this kind of bugs, or click Cancel to stay in 'jQuery' mode.")) { - // We will not display this warning again for one day - cookies.setItem("warnedServiceWorkerMode", true, 86400); - return true; - } else { - return false; - } - } - return true; - } - // At launch, we try to set the last content injection mode (stored in a cookie) var lastContentInjectionMode = cookies.getItem('lastContentInjectionMode'); if (lastContentInjectionMode) { @@ -1313,7 +1317,6 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' * @type Array. */ var storages = []; - //var storages = [appFolder.path]; //UWP @UWP function searchForArchivesInPreferencesOrStorage() { // First see if the list of archives is stored in the cookie var listOfArchivesFromCookie = cookies.getItem("listOfArchives"); @@ -1600,16 +1603,16 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' function displayFileSelect() { document.getElementById('openLocalFiles').style.display = 'block'; // Set the main drop zone - scrollBoxDropZone.addEventListener('dragover', handleGlobalDragover, false); - scrollBoxDropZone.addEventListener('dragleave', function (e) { + scrollBoxDropZone.addEventListener('dragover', handleGlobalDragover); + scrollBoxDropZone.addEventListener('dragleave', function(e) { configDropZone.style.border = ''; }); // Also set a global drop zone (allows us to ensure Config is always displayed for the file drop) globalDropZone.addEventListener('dragover', function (e) { e.preventDefault(); - if (document.getElementById('configuration').style.display === 'none') document.getElementById('btnConfigure').click(); + if (configDropZone.style.display === 'none') document.getElementById('btnConfigure').click(); e.dataTransfer.dropEffect = 'link'; - }, false); + }); globalDropZone.addEventListener('drop', handleFileDrop); // This handles use of the file picker document.getElementById('archiveFiles').addEventListener('change', setLocalArchiveFromFileSelect); @@ -1825,11 +1828,12 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' request.responseType = "blob"; request.onreadystatechange = function () { if (request.readyState === XMLHttpRequest.DONE) { - if (request.status >= 200 && request.status < 300 || request.status === 0) { + if ((request.status >= 200 && request.status < 300) || request.status === 0) { // Hack to make this look similar to a file request.response.name = url; deferred.resolve(request.response); - } else { + } + else { deferred.reject("HTTP status " + request.status + " when reading " + url); } } @@ -2077,9 +2081,9 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies', 'q', 'module' function findDirEntryFromDirEntryIdAndLaunchArticleRead(dirEntryId) { if (selectedArchive.isReady()) { var dirEntry = selectedArchive.parseDirEntryId(dirEntryId); - // Remove focus from search field to hide keyboard - document.getElementById('searchArticles').focus(); - document.getElementById('searchingArticles').style.display = 'block'; + // Remove focus from search field to hide keyboard and to allow navigation keys to be used + document.getElementById('articleContent').contentWindow.focus(); + $("#searchingArticles").show(); if (dirEntry.isRedirect()) { selectedArchive.resolveRedirect(dirEntry, readArticle); } else { diff --git a/www/js/lib/images.js b/www/js/lib/images.js new file mode 100644 index 00000000..3abe0739 --- /dev/null +++ b/www/js/lib/images.js @@ -0,0 +1,183 @@ +/** + * images.js : Functions for the processing of images + * + * Copyright 2013-2019 Mossroy and contributors + * License GPL v3: + * + * This file is part of Kiwix. + * + * Kiwix is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Kiwix is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Kiwix (file LICENSE-GPLv3.txt). If not, see + */ +'use strict'; +define(['uiUtil', 'cookies'], function(uiUtil, cookies) { + + /** + * Declare a module-specific variable defining the contentInjectionMode. Its value may be + * changed in setContentInjectionMode() + */ + var contentInjectionMode = cookies.getItem('lastContentInjectionMode'); + + /** + * Iterates over an array or collection of image nodes, extracting the image data from the ZIM + * and inserting a BLOB URL to each image in the image's src attribute + * + * @param {Object} images An array or collection of DOM image nodes + * @param {Object} selectedArchive The ZIM archive picked by the user + */ + function extractImages(images, selectedArchive) { + Array.prototype.slice.call(images).forEach(function (image) { + var imageUrl = image.getAttribute('data-kiwixurl'); + if (!imageUrl) return; + image.removeAttribute('data-kiwixurl'); + var title = decodeURIComponent(imageUrl); + if (contentInjectionMode === 'serviceworker') { + image.src = imageUrl + '?kiwix-display'; + } else { + selectedArchive.getDirEntryByTitle(title).then(function (dirEntry) { + return selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) { + image.style.background = ''; + var mimetype = dirEntry.getMimetype(); + uiUtil.feedNodeWithBlob(image, 'src', content, mimetype); + }); + }).fail(function (e) { + console.error('Could not find DirEntry for image: ' + title, e); + }); + } + }); + } + + /** + * Iterates over an array or collection of image nodes, preparing each node for manual image + * extraction when user taps the indicated area + * + * @param {Object} images An array or collection of DOM image nodes + * @param {Object} selectedArchive The ZIM archive picked by the user + */ + function setupManualImageExtraction(images, selectedArchive) { + Array.prototype.slice.call(images).forEach(function (image) { + var originalHeight = image.getAttribute('height') || ''; + //Ensure 36px clickable image height so user can request images by tapping + image.height = '36'; + if (contentInjectionMode ==='jquery') { + image.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"; + image.style.background = 'lightblue'; + } + image.dataset.kiwixheight = originalHeight; + image.addEventListener('click', function (e) { + // If the image clicked on hasn't been extracted yet, cancel event bubbling, so that we don't navigate + // away from the article if the image is hyperlinked + if (image.dataset.kiwixurl) { + e.preventDefault(); + e.stopPropagation(); + } + var visibleImages = queueImages(images); + visibleImages.forEach(function (image) { + if (image.dataset.kiwixheight) image.height = image.dataset.kiwixheight; + else image.removeAttribute('height'); + // Line below provides a visual indication to users of slow browsers that their click has been registered and + // images are being fetched; this is not necessary in SW mode because SW is only supported by faster browsers + if (contentInjectionMode ==='jquery') image.style.background = 'lightgray'; + }); + extractImages(visibleImages, selectedArchive); + }); + }); + } + + /** + * Sorts an array or collection of image nodes, returning a list of those that are inside the visible viewport + * + * @param {Object} images An array or collection of DOM image nodes + * @returns {Array} An array of image nodes that are within the visible viewport + */ + function queueImages(images) { + var visibleImages = []; + for (var i = 0, l = images.length; i < l; i++) { + if (!images[i].dataset.kiwixurl) continue; + if (uiUtil.isElementInView(images[i])) { + visibleImages.push(images[i]); + } + } + return visibleImages; + } + + /** + * Prepares an array or collection of image nodes that have been disabled in Service Worker for manual extraction + * + * @param {Object} images An array or collection of DOM image nodes + * @param {String} displayType If 'progressive', lazyLoad will be used; if 'manual', setupManualImageExtraction will be used + */ + function prepareImagesServiceWorker (images, displayType) { + var zimImages = []; + for (var i = 0, l = images.length; i < l; i++) { + // DEV: make sure list of file types here is the same as the list in Service Worker code + if (/(^|\/)[IJ]\/.*\.(jpe?g|png|svg|gif)($|[?#])/i.test(images[i].src)) { + images[i].dataset.kiwixurl = images[i].getAttribute('src'); + zimImages.push(images[i]); + } + } + if (displayType === 'manual') { + setupManualImageExtraction(zimImages); + } else { + lazyLoad(zimImages); + } + } + + /** + * Processes an array or collection of image nodes so that they will be lazy loaded (progressive extraction) + * + * @param {Object} images And array or collection of DOM image nodes which will be processed for + * progressive image extraction + * @param {Object} selectedArchive The archive picked by the user + */ + function lazyLoad(images, selectedArchive) { + var queue; + var getImages = function() { + queue = queueImages(images); + extractImages(queue, selectedArchive); + }; + getImages(); + // Sometimes the page hasn't been rendered when getImages() is run, especially in Firefox, so run again after delay + setTimeout(getImages, 700); + if (queue.length === images.length) return; + // There are images remaining, so set up an event listener to load more images once user has stopped scrolling the iframe + var iframe = document.getElementById('articleContent'); + var iframeWindow = iframe.contentWindow; + iframeWindow.addEventListener('scroll', function() { + clearTimeout(timer); + // Waits for a period after scroll start to start checking for images + var timer = setTimeout(getImages, 600); + }); + } + + /** + * A utility to set the contentInjectionmode in this module + * It should be called when the user changes the contentInjectionMode + * + * @param {String} injectionMode The contentInjectionMode selected by the user + */ + function setContentInjectionMode(injectionMode) { + contentInjectionMode = injectionMode; + } + + /** + * Functions and classes exposed by this module + */ + return { + extractImages: extractImages, + setupManualImageExtraction: setupManualImageExtraction, + prepareImagesServiceWorker: prepareImagesServiceWorker, + lazyLoad: lazyLoad, + setContentInjectionMode: setContentInjectionMode + }; +});