Migrate to extension Manifest v3 for Chromium #755 (#984)

Both MV2 and MV3 are built for Chromium. Only MV2 for Firefox for now (until Service Workers are supported as backgroundscript.js).
This commit is contained in:
Jaifroid 2023-06-13 21:21:40 +01:00 committed by GitHub
parent bdfe50562c
commit bd7393e921
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 365 additions and 287 deletions

View File

@ -17,6 +17,8 @@ module.exports = {
'no-extra-parens': 1,
'no-unused-expressions': 1,
'no-unused-vars': 1,
'n/no-callback-literal': 0
'n/no-callback-literal': 0,
'object-shorthand': 0,
'multiline-ternary': 0
}
}

View File

@ -19,11 +19,11 @@ Please follow these guidelines when contributing:
- be sure to test your fix in both "JQuery" mode and "Service Worker" mode (see Configuration);
- run the Unit tests (see below) in at least the above browsers.
If all the tests are working fine, you can finally test the extension versions, like this:
If all the tests are working fine, you can finally test the extension versions. Plese note that we are using Manifest V3 for the Chromium extensions, and Manifest V2
for the Firefox extension, so there are different instructions for the two browser families:
- Remove the '-WIP' from the version key from the manifest.json file present in the root of this repo;
- In Chromium, you can install the extension by loading the root folder with Extensions -> Load Unpacked (with Developer Mode turned ON) -> select the root folder of the repository;
- In Firefox, you can load an extension with Manage Your Extensions -> Debug Add-ons -> Load Temporary Add-on, and then pick any file in the repository.
- In Firefox, you need to rename manifest.json to manifest.v3.json, and then rename manifest.v2.json to manifest.json. Then you can load the extension with Manage Your Extensions -> Debug Add-ons -> Load Temporary Add-on, and then pick any file in the repository.
If your feature works and tests are passing, make a PR, describe the testing you have done, and ask for a code review.

View File

@ -1,5 +1,5 @@
/**
* backgroundscript.js: Background script for the WebExtension
* backgroundscript.js: Background script for the WebExtension Manifest V2
*
* Copyright 2017 Mossroy and contributors
* License GPL v3:
@ -20,22 +20,20 @@
* along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/>
*/
/* global chrome, browser */
// In order to work on both Firefox and Chromium/Chrome (and derivatives).
// browser and chrome variables expose almost the same APIs
var genericBrowser;
if (typeof browser !== 'undefined') {
// Firefox
genericBrowser = browser;
}
else {
} else {
// Chromium/Chrome
genericBrowser = chrome;
}
genericBrowser.browserAction.onClicked.addListener(handleClick);
function handleClick(event) {
genericBrowser.tabs.create({
url: genericBrowser.runtime.getURL('/www/index.html')
});
}
genericBrowser.browserAction.onClicked.addListener(function () {
var newURL = chrome.runtime.getURL('www/index.html');
chrome.tabs.create({ url: newURL });
});

View File

@ -1,9 +1,9 @@
{
"manifest_version": 2,
"manifest_version": 3,
"name": "Kiwix",
"version": "3.8.1",
"description": "Kiwix : offline Wikipedia reader",
"description": "Kiwix Offline Browser",
"icons": {
"16": "www/img/icons/kiwix-16.png",
@ -16,7 +16,7 @@
"128": "www/img/icons/kiwix-128.png"
},
"browser_action": {
"action": {
"default_icon": {
"16": "www/img/icons/kiwix-16.png",
"19": "www/img/icons/kiwix-19.png",
@ -26,22 +26,24 @@
},
"default_title": "Kiwix"
},
"applications": {
"gecko": {
"id": "kiwix-html5-unlisted@kiwix.org"
}
},
"web_accessible_resources": ["www/index.html"],
"background": {
"scripts": ["webextension/backgroundscript.js"]
"service_worker": "service-worker.js"
},
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"permissions": ["storage", "activeTab", "scripting"],
"author": "mossroy",
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';",
"sandbox": "sandbox allow-scripts allow-downloads allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';"
},
"web_accessible_resources": [{
"resources": ["www/index.html", "www/article.html"],
"matches": ["https://*.kiwix.org/*"]
}],
"author": "Kiwix",
"homepage_url": "https://www.kiwix.org",
"offline_enabled": true
}

47
manifest.v2.json Normal file
View File

@ -0,0 +1,47 @@
{
"manifest_version": 2,
"name": "Kiwix",
"version": "3.8.1",
"description": "Kiwix : offline Wikipedia reader",
"icons": {
"16": "www/img/icons/kiwix-16.png",
"19": "www/img/icons/kiwix-19.png",
"32": "www/img/icons/kiwix-32.png",
"38": "www/img/icons/kiwix-38.png",
"48": "www/img/icons/kiwix-48.png",
"64": "www/img/icons/kiwix-64.png",
"90": "www/img/icons/kiwix-90.png",
"128": "www/img/icons/kiwix-128.png"
},
"browser_action": {
"default_icon": {
"16": "www/img/icons/kiwix-16.png",
"19": "www/img/icons/kiwix-19.png",
"32": "www/img/icons/kiwix-32.png",
"38": "www/img/icons/kiwix-38.png",
"64": "www/img/icons/kiwix-64.png"
},
"default_title": "Kiwix"
},
"applications": {
"gecko": {
"id": "kiwix-html5-unlisted@kiwix.org"
}
},
"web_accessible_resources": ["www/index.html"],
"background": {
"scripts": ["backgroundscript.js"]
},
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"author": "mossroy",
"homepage_url": "https://www.kiwix.org",
"offline_enabled": true
}

View File

@ -55,7 +55,7 @@ fi
# Copy only the necessary files in a temporary directory
mkdir -p tmp
rm -rf tmp/*
cp -r www webextension manifest.json manifest.webapp LICENSE-GPLv3.txt service-worker.js README.md tmp/
cp -r www manifest.json manifest.v2.json manifest.webapp LICENSE-GPLv3.txt service-worker.js README.md tmp/
# Remove unwanted files
rm -f tmp/www/js/lib/libzim-*dev.*
@ -64,8 +64,10 @@ rm -f tmp/www/js/lib/libzim-*dev.*
regexpNumericVersion='^[0-9\.]+$'
if [[ $VERSION =~ $regexpNumericVersion ]] ; then
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.v2.json
else
sed -i -e "s/$VERSION_TO_REPLACE/$MAJOR_NUMERIC_VERSION/" tmp/manifest.json
sed -i -e "s/$VERSION_TO_REPLACE/$MAJOR_NUMERIC_VERSION/" tmp/manifest.v2.json
fi
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.webapp
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/service-worker.js
@ -73,17 +75,26 @@ sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/www/js/app.js
mkdir -p build
rm -rf build/*
# Package for Chromium/Chrome
scripts/package_chrome_extension.sh $DRYRUN $TAG -v $VERSION
# Package for Chromium/Chrome with Manifest V3
scripts/package_chrome_extension.sh -m 3 $DRYRUN $TAG -v $VERSION
# Package for Chromium/Chrome with Manifest V2
cp backgroundscript.js tmp/
rm tmp/manifest.json
mv tmp/manifest.v2.json tmp/manifest.json
scripts/package_chrome_extension.sh -m 2 $DRYRUN $TAG -v $VERSION
# Package for Firefox and Firefox OS
# We have to put a unique version string inside the manifest.json (which Chrome might not have accepted)
# So we take the original manifest again, and replace the version inside it again
cp manifest.json tmp/
# So we take the original manifest v2 again, and replace the version inside it again
cp manifest.v2.json tmp/manifest.json
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION_FOR_MOZILLA_MANIFEST/" tmp/manifest.json
echo ""
scripts/package_firefox_extension.sh $DRYRUN $TAG -v $VERSION
echo ""
scripts/package_firefoxos_app.sh $DRYRUN $TAG -v $VERSION
cp -f ubuntu_touch/* tmp/
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json
echo ""
scripts/package_ubuntu_touch_app.sh $DRYRUN $TAG -v $VERSION
# Change permissions on source files to match those expected by the server

View File

@ -3,19 +3,23 @@ BASEDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"/..
cd "$BASEDIR"
# Reading arguments
while getopts tdv: option; do
while getopts m:tdv: option; do
case "${option}" in
m) MV=$OPTARG;; # Optionally indicates the manifest version we're using (2 or 3); if present, the version will be added to filename
t) TAG="-t";; # Indicates that we're releasing a public version from a tag
d) DRYRUN="-d";; # Indicates a dryrun test, that does not modify anything on the network
v) VERSION=${OPTARG};;
esac
done
if [ -n $MV ]; then
echo -e "\nManifest version requested: $MV"
VERSION="MV$MV-$VERSION"
fi
echo "Packaging unsigned Chrome extension, version $VERSION"
cd tmp
zip -r ../build/kiwix-chrome-unsigned-extension-$VERSION.zip www webextension manifest.json LICENSE-GPLv3.txt service-worker.js README.md
zip -r ../build/kiwix-chrome-unsigned-extension-$VERSION.zip www manifest.json LICENSE-GPLv3.txt service-worker.js README.md
cd ..
if [ "${TAG}zz" == "zz" ]; then
if [ -z $TAG ]; then
# Package the extension with Chrome or Chromium, if we're not packaging a public version
if hash chromium-browser 2>/dev/null
then
@ -28,6 +32,7 @@ if [ "${TAG}zz" == "zz" ]; then
echo "Signing the extension for $CHROME_BIN, version $VERSION"
$CHROME_BIN --no-sandbox --pack-extension=tmp --pack-extension-key=./scripts/kiwix-html5.pem
mv tmp.crx build/kiwix-chrome-signed-extension-$VERSION.crx
ls -l build/kiwix-chrome-signed-extension-$VERSION.crx
else
echo "This unsigned extension must be manually uploaded to Google to be signed and distributed from their store"
fi

View File

@ -2,27 +2,29 @@
* 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
*
*
* Copyright 2022 Mossroy, Jaifroid 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 <http://www.gnu.org/licenses/>
*/
'use strict';
/* global chrome */
/**
* App version number - ENSURE IT MATCHES VALUE IN app.js
* DEV: Changing this will cause the browser to recognize that the Service Worker has changed, and it will
@ -60,10 +62,9 @@ var useAssetsCache = true;
* This is an expert setting in Configuration
* @type {Boolean}
*/
var useAppCache = true;
var useAppCache = true;
/**
/**
* A regular expression that matches the Content-Types of assets that may be stored in ASSETS_CACHE
* Add any further Content-Types you wish to cache to the regexp, separated by '|'
* @type {RegExp}
@ -78,7 +79,7 @@ var regexpCachedContentTypes = /text\/css|text\/javascript|application\/javascri
*/
var regexpExcludedURLSchema = /^(?:file|chrome-extension|example-extension):/i;
/**
/**
* Pattern for ZIM file namespace: see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces
* In our case, there is also the ZIM file name used as a prefix in the URL
* @type {RegExp}
@ -91,7 +92,7 @@ const regexpZIMUrlWithNamespace = /(?:^|\/)([^/]+\/)([-ABCIJMUVWX])\/(.+)/;
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
* But, in our case, we send a header to tell the browser we only accept the bytes unit.
* I did not see multiple ranges asked by a browser.
*
*
* @type {RegExp}
*/
const regexpByteRangeHeader = /^\s*bytes=(\d+)-/;
@ -99,74 +100,84 @@ const regexpByteRangeHeader = /^\s*bytes=(\d+)-/;
/**
* The list of files that the app needs in order to run entirely from offline code
*/
let precacheFiles = [
".", // This caches the redirect to www/index.html, in case a user launches the app from its root directory
"manifest.json",
"service-worker.js",
"www/css/app.css",
"www/css/bootstrap.css",
"www/css/kiwixJS_invert.css",
"www/css/kiwixJS_mwInvert.css",
"www/css/transition.css",
"www/img/icons/kiwix-256.png",
"www/img/icons/kiwix-32.png",
"www/img/icons/kiwix-60.png",
"www/img/spinner.gif",
"www/img/Icon_External_Link.png",
"www/index.html",
"www/article.html",
"www/main.html",
"www/js/app.js",
"www/js/init.js",
"www/js/lib/abstractFilesystemAccess.js",
"www/js/lib/arrayFromPolyfill.js",
"www/js/lib/bootstrap.bundle.js",
"www/js/lib/filecache.js",
"www/js/lib/jquery-3.7.0.slim.min.js",
"www/js/lib/promisePolyfill.js",
"www/js/lib/require.js",
"www/js/lib/settingsStore.js",
"www/js/lib/uiUtil.js",
"www/js/lib/utf8.js",
"www/js/lib/util.js",
"www/js/lib/xzdec_wrapper.js",
"www/js/lib/zstddec_wrapper.js",
"www/js/lib/zimArchive.js",
"www/js/lib/zimArchiveLoader.js",
"www/js/lib/zimDirEntry.js",
"www/js/lib/zimfile.js",
"www/js/lib/fontawesome/fontawesome.js",
"www/js/lib/fontawesome/solid.js"
const precacheFiles = [
'.', // This caches the redirect to www/index.html, in case a user launches the app from its root directory
'manifest.json',
'service-worker.js',
'www/css/app.css',
'www/css/bootstrap.css',
'www/css/kiwixJS_invert.css',
'www/css/kiwixJS_mwInvert.css',
'www/css/transition.css',
'www/img/icons/kiwix-256.png',
'www/img/icons/kiwix-32.png',
'www/img/icons/kiwix-60.png',
'www/img/spinner.gif',
'www/img/Icon_External_Link.png',
'www/index.html',
'www/article.html',
'www/main.html',
'www/js/app.js',
'www/js/init.js',
'www/js/lib/abstractFilesystemAccess.js',
'www/js/lib/arrayFromPolyfill.js',
'www/js/lib/bootstrap.bundle.js',
'www/js/lib/filecache.js',
'www/js/lib/jquery-3.7.0.slim.min.js',
'www/js/lib/promisePolyfill.js',
'www/js/lib/require.js',
'www/js/lib/settingsStore.js',
'www/js/lib/uiUtil.js',
'www/js/lib/utf8.js',
'www/js/lib/util.js',
'www/js/lib/xzdec_wrapper.js',
'www/js/lib/zstddec_wrapper.js',
'www/js/lib/zimArchive.js',
'www/js/lib/zimArchiveLoader.js',
'www/js/lib/zimDirEntry.js',
'www/js/lib/zimfile.js',
'www/js/lib/fontawesome/fontawesome.js',
'www/js/lib/fontawesome/solid.js'
];
if ('WebAssembly' in self) {
precacheFiles.push(
"www/js/lib/xzdec-wasm.js",
"www/js/lib/xzdec-wasm.wasm",
"www/js/lib/zstddec-wasm.js",
"www/js/lib/zstddec-wasm.wasm",
"www/js/lib/libzim-wasm.js",
"www/js/lib/libzim-wasm.wasm"
);
precacheFiles.push(
'www/js/lib/xzdec-wasm.js',
'www/js/lib/xzdec-wasm.wasm',
'www/js/lib/zstddec-wasm.js',
'www/js/lib/zstddec-wasm.wasm',
'www/js/lib/libzim-wasm.js',
'www/js/lib/libzim-wasm.wasm'
);
} else {
precacheFiles.push(
"www/js/lib/xzdec-asm.js",
"www/js/lib/zstddec-asm.js",
"www/js/lib/libzim-asm.js"
);
precacheFiles.push(
'www/js/lib/xzdec-asm.js',
'www/js/lib/zstddec-asm.js',
'www/js/lib/libzim-asm.js'
);
}
/**
* If we're in a Chromium extension, add a listener to launch the tab when the icon is clicked
*/
if (typeof chrome !== 'undefined' && chrome.action) {
chrome.action.onClicked.addListener(function () {
var newURL = chrome.runtime.getURL('www/index.html');
chrome.tabs.create({ url: newURL });
});
}
// Process install event
self.addEventListener("install", function (event) {
console.debug("[SW] Install Event processing");
self.addEventListener('install', function (event) {
console.debug('[SW] Install Event processing');
// DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate...
// self.skipWaiting();
// We try to circumvent the browser's cache by adding a header to the Request, and it ensures all files are explicitly versioned
var requests = precacheFiles.map(function (urlPath) {
return new Request(urlPath + '?v' + appVersion, { cache: 'no-cache' });
});
if (!regexpExcludedURLSchema.test(requests[0].url)) event.waitUntil(
caches.open(APP_CACHE).then(function (cache) {
if (!regexpExcludedURLSchema.test(requests[0].url)) {
event.waitUntil(caches.open(APP_CACHE).then(function (cache) {
return Promise.all(
requests.map(function (request) {
return fetch(request).then(function (response) {
@ -178,8 +189,8 @@ self.addEventListener("install", function (event) {
});
})
);
})
);
}));
}
});
// Allow sw to control current page
@ -211,7 +222,7 @@ let fetchCaptureEnabled = false;
*/
self.addEventListener('fetch', function (event) {
// Only cache GET requests
if (event.request.method !== "GET") return;
if (event.request.method !== 'GET') return;
var rqUrl = event.request.url;
var urlObject = new URL(rqUrl);
// Test the URL with parameters removed
@ -225,13 +236,13 @@ self.addEventListener('fetch', function (event) {
event.respondWith(
// First see if the content is in the cache
fromCache(cache, rqUrl).then(function (response) {
// The response was found in the cache so we respond with it
// The response was found in the cache so we respond with it
return response;
}, function () {
// The response was not found in the cache so we look for it in the ZIM
// and add it to the cache if it is an asset type (css or js)
if (cache === ASSETS_CACHE && regexpZIMUrlWithNamespace.test(strippedUrl)) {
let range = event.request.headers.get('range');
const range = event.request.headers.get('range');
return fetchUrlFromZIM(urlObject, range).then(function (response) {
// Add css or js assets to ASSETS_CACHE (or update their cache entries) unless the URL schema is not supported
if (regexpCachedContentTypes.test(response.headers.get('Content-Type')) &&
@ -252,7 +263,7 @@ self.addEventListener('fetch', function (event) {
}
return response;
}).catch(function (error) {
console.debug("[SW] Network request failed and no cache.", error);
console.debug('[SW] Network request failed and no cache.', error);
});
}
})
@ -262,7 +273,7 @@ self.addEventListener('fetch', function (event) {
/**
* Handle custom commands sent from app.js
*/
self.addEventListener('message', function (event) {
self.addEventListener('message', function (event) {
if (event.data.action) {
if (event.data.action === 'init') {
// On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener
@ -287,7 +298,7 @@ self.addEventListener('fetch', function (event) {
if (useAppCache !== oldValue) console.debug('[SW] Use of appCache was switched to: ' + useAppCache);
}
if (event.data.action === 'getCacheNames') {
event.ports[0].postMessage({ 'app': APP_CACHE, 'assets': ASSETS_CACHE });
event.ports[0].postMessage({ app: APP_CACHE, assets: ASSETS_CACHE });
}
if (event.data.action.checkCache) {
// Checks and returns the caching strategy: checkCache key should contain a sample URL string to test
@ -300,12 +311,12 @@ self.addEventListener('fetch', function (event) {
/**
* Handles URLs that need to be extracted from the ZIM archive
*
*
* @param {URL} urlObject The URL object to be processed for extraction from the ZIM
* @param {String} range Optional byte range string
* @returns {Promise<Response>} A Promise for the Response, or rejects with the invalid message port data
*/
function fetchUrlFromZIM(urlObject, range) {
function fetchUrlFromZIM (urlObject, range) {
return new Promise(function (resolve, reject) {
// Note that titles may contain bare question marks or hashes, so we must use only the pathname without any URL parameters.
// Be sure that you haven't encoded any querystring along with the URL.
@ -330,14 +341,14 @@ function fetchUrlFromZIM(urlObject, range) {
headers.set('Content-Security-Policy', "default-src 'self' data: blob: about: chrome-extension: https://moz-extension.kiwix.org https://kiwix.github.io 'unsafe-inline' 'unsafe-eval'; sandbox allow-scripts allow-same-origin allow-modals allow-popups allow-forms allow-downloads;");
headers.set('Referrer-Policy', 'no-referrer');
if (contentType) headers.set('Content-Type', contentType);
// Test if the content is a video or audio file. In this case, Chrome & Edge need us to support ranges.
// 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)) {
headers.set('Accept-Ranges', 'bytes');
}
var slicedData = msgPortEvent.data.content;
if (range) {
// The browser asks for a range of bytes (usually for a video or audio stream)
@ -347,22 +358,22 @@ function fetchUrlFromZIM(urlObject, range) {
// So it's probably better to send all we have: hopefully it will avoid some subsequent requests of
// the browser to get the following chunks (which would trigger some other complete reads in the ZIM file)
// This might be improved in the future with the libzim wasm backend, that should be able to handle ranges.
let partsOfRangeHeader = regexpByteRangeHeader.exec(range);
let begin = partsOfRangeHeader[1];
let end = contentLength - 1;
const partsOfRangeHeader = regexpByteRangeHeader.exec(range);
const begin = partsOfRangeHeader[1];
const end = contentLength - 1;
slicedData = slicedData.slice(begin);
headers.set('Content-Range', 'bytes ' + begin + '-' + end + '/' + contentLength);
headers.set('Content-Length', end - begin + 1);
}
var responseInit = {
// HTTP status is usually 200, but has to bee 206 when partial content (range) is sent
status: range ? 206 : 200,
statusText: 'OK',
headers: headers
headers
};
var httpResponse = new Response(slicedData, responseInit);
// Let's send the content back from the ServiceWorker
@ -374,8 +385,8 @@ function fetchUrlFromZIM(urlObject, range) {
}
};
outgoingMessagePort.postMessage({
'action': 'askForContent',
'title': titleWithNameSpace
action: 'askForContent',
title: titleWithNameSpace
}, [messageChannel.port2]);
});
}
@ -386,12 +397,16 @@ function fetchUrlFromZIM(urlObject, range) {
* @param {String} requestUrl The Request URL to fulfill from cache
* @returns {Promise<Response>} A Promise for the cached Response, or rejects with strings 'disabled' or 'no-match'
*/
function fromCache(cache, requestUrl) {
function fromCache (cache, requestUrl) {
// Prevents use of Cache API if user has disabled it
if (!(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) return Promise.reject('disabled');
if (!(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) {
return Promise.reject(new Error('disabled'));
}
return caches.open(cache).then(function (cacheObj) {
return cacheObj.match(requestUrl).then(function (matching) {
if (!matching || matching.status === 404) return Promise.reject('no-match');
if (!matching || matching.status === 404) {
return Promise.reject(new Error('no-match'));
}
console.debug('[SW] Supplying ' + requestUrl + ' from ' + cache + '...');
return matching;
});
@ -405,10 +420,11 @@ function fromCache(cache, requestUrl) {
* @param {Response} response The Response received from the server/ZIM
* @returns {Promise} A Promise for the update action
*/
function updateCache(cache, request, response) {
function updateCache (cache, request, response) {
// Prevents use of Cache API if user has disabled it
if (!response.ok || !(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE))
if (!response.ok || !(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) {
return Promise.resolve();
}
return caches.open(cache).then(function (cacheObj) {
console.debug('[SW] Adding ' + (request.url || request) + ' to ' + cache + '...');
return cacheObj.put(request, response);
@ -421,16 +437,16 @@ function updateCache(cache, request, response) {
* @param {String} url A URL to test against excludedURLSchema
* @returns {Promise<Array>} A Promise for an array of format [cacheType, cacheDescription, assetCount]
*/
function testCacheAndCountAssets(url) {
function testCacheAndCountAssets (url) {
if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', 'custom', 'Custom', '-']);
if (!useAssetsCache) return Promise.resolve(['none', 'none', 'None', 0]);
return caches.open(ASSETS_CACHE).then(function (cache) {
return cache.keys().then(function (keys) {
return ['cacheAPI', ASSETS_CACHE, 'Cache API', keys.length];
}).catch(function(err) {
}).catch(function (err) {
return err;
});
}).catch(function(err) {
}).catch(function (err) {
return err;
});
}

View File

@ -30,9 +30,8 @@
// This uses require.js to structure javascript:
// http://requirejs.org/docs/api.html#define
define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesystemAccess'],
define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore', 'abstractFilesystemAccess'],
function ($, zimArchiveLoader, uiUtil, settingsStore, abstractFilesystemAccess) {
/**
* 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)
@ -108,7 +107,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
(isServiceWorkerAvailable() ? 'serviceworker' : 'jquery');
// A parameter to circumvent anti-fingerprinting technology in browsers that do not support WebP natively by substituting images
// directly with the canvas elements produced by the WebP polyfill [kiwix-js #835]. NB This is only currently used in jQuery mode.
params['useCanvasElementsForWebpTranscoding']; // Value is determined in uiUtil.determineCanvasElementsWorkaround(), called when setting the content injection mode
params['useCanvasElementsForWebpTranscoding'] = null; // Value is determined in uiUtil.determineCanvasElementsWorkaround(), called when setting the content injection mode
// An object to hold the current search and its state (allows cancellation of search across modules)
appstate['search'] = {
@ -182,11 +181,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
document.getElementById('bypassAppCacheCheck').checked = !params.appCache;
document.getElementById('appVersion').textContent = 'Kiwix ' + params.appVersion;
// We check here if we have to warn the user that we switched to ServiceWorkerMode
// This is only needed if the ServiceWorker mode is available, or we are in a Firefox Extension that supports Service Workers
// This is only needed if the ServiceWorker mode is available, or we are in an Extension that supports Service Workers
// outside of the extension environment, AND the user's settings are stuck on jQuery mode, AND the user has not already been
// alerted about the switch to ServiceWorker mode by default
if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^moz-extension:/i.test(window.location.protocol))
&& params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) {
if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^(moz|chrome)-extension:/i.test(window.location.protocol)) &&
params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) {
// Attempt to upgrade user to ServiceWorker mode
params.contentInjectionMode = 'serviceworker';
} else if (params.contentInjectionMode === 'serviceworker') {
@ -329,16 +328,12 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
$('#prefix').on('keyup', function (e) {
if (selectedArchive !== null && selectedArchive.isReady()) {
// Prevent processing by keyup event if we already handled the keypress in keydown event
if (keyPressHandled)
keyPressHandled = false;
else
onKeyUpPrefix(e);
if (keyPressHandled) { keyPressHandled = false; } else { onKeyUpPrefix(e); }
}
});
// Restore the search results if user goes back into prefix field
$('#prefix').on('focus', function () {
if (document.getElementById('prefix').value !== '')
document.getElementById('articleListWithHeader').style.display = '';
if (document.getElementById('prefix').value !== '') { document.getElementById('articleListWithHeader').style.display = ''; }
});
// Hide the search results if user moves out of prefix field
$('#prefix').on('blur', function () {
@ -374,7 +369,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
});
$('#btnTop').on('click', function () {
var articleContent = document.getElementById('articleContent');
articleContent.contentWindow.scrollTo({top: 0, behavior: 'smooth'});
articleContent.contentWindow.scrollTo({ top: 0, behavior: 'smooth' });
// We return true, so that the link to #top is still triggered (useful in the About section)
return true;
});
@ -401,7 +396,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
document.getElementById('prefix').value = '';
document.getElementById('prefix').focus();
var articleList = document.getElementById('articleList');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
document.getElementById('searchingArticles').style.display = 'none';
@ -505,7 +500,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
refreshCacheStatus();
});
document.getElementById('disableDragAndDropCheck').addEventListener('change', function () {
params.disableDragAndDrop = this.checked ? true : false;
params.disableDragAndDrop = !!this.checked;
settingsStore.setItem('disableDragAndDrop', params.disableDragAndDrop, Infinity);
uiUtil.systemAlert('<p>We will now attempt to reload the app to apply the new setting.</p>' +
'<p>(If you cancel, then the setting will only be applied when you next start the app.)</p>', 'Reload app', true).then(function (result) {
@ -515,20 +510,20 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
});
});
$('input:checkbox[name=hideActiveContentWarning]').on('change', function () {
params.hideActiveContentWarning = this.checked ? true : false;
params.hideActiveContentWarning = !!this.checked;
settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity);
});
$('input:checkbox[name=showUIAnimations]').on('change', function () {
params.showUIAnimations = this.checked ? true : false;
params.showUIAnimations = !!this.checked;
settingsStore.setItem('showUIAnimations', params.showUIAnimations, Infinity);
});
$('input:checkbox[name=useHomeKeyToFocusSearchBar]').on('change', function () {
params.useHomeKeyToFocusSearchBar = this.checked ? true : false;
params.useHomeKeyToFocusSearchBar = !!this.checked;
settingsStore.setItem('useHomeKeyToFocusSearchBar', params.useHomeKeyToFocusSearchBar, Infinity);
switchHomeKeyToFocusSearchBar();
});
$('input:checkbox[name=openExternalLinksInNewTabs]').on('change', function () {
params.openExternalLinksInNewTabs = this.checked ? true : false;
params.openExternalLinksInNewTabs = !!this.checked;
settingsStore.setItem('openExternalLinksInNewTabs', params.openExternalLinksInNewTabs, Infinity);
});
document.getElementById('appThemeSelect').addEventListener('change', function (e) {
@ -578,12 +573,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
uiUtil.checkUpdateStatus(appstate);
}, 10000);
// Adds an event listener to kiwix logo and bottom navigation bar which gets triggered when these elements are dragged.
// Returning false prevents their dragging (which can cause some unexpected behavior)
// Doing that in javascript is the only way to make it cross-browser compatible
document.getElementById('kiwixLogo').ondragstart=function () {return false;}
document.getElementById('navigationButtons').ondragstart=function () {return false;}
document.getElementById('kiwixLogo').ondragstart = function () { return false; }
document.getElementById('navigationButtons').ondragstart = function () { return false; }
// focus search bar (#prefix) if Home key is pressed
function focusPrefixOnHomeKey (event) {
@ -592,7 +586,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// wait to prevent interference with scrolling (default action)
setTimeout(function () {
document.getElementById('prefix').focus();
},0);
}, 0);
}
}
// switch on/off the feature to use Home Key to focus search bar
@ -602,8 +596,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var isIframeAccessible = true;
try {
iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
}
catch (err) {
} catch (err) {
console.error('The iframe is probably not accessible', err);
isIframeAccessible = false;
}
@ -615,10 +608,8 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// only for initial empty iFrame loaded using `src` attribute
// in any other case listener gets removed on reloading of iFrame content
iframeContentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
}
// when the feature is not active
else {
// remove event listener for window(outside iframe)
} else {
// When the feature is not active, remove event listener for window (outside iframe)
window.removeEventListener('keydown', focusPrefixOnHomeKey);
// if feature is deactivated and no zim content is loaded yet
iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
@ -710,7 +701,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}
apiName = params.decompressorAPI.errorStatus || apiName || 'Not initialized';
// innerHTML is used here because the API name may contain HTML entities like &nbsp;
decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName ;
decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName;
// Update Search Provider
uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider);
// Update PWA origin
@ -859,7 +850,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
uriParams += '&appTheme=' + params.appTheme;
uriParams += '&showUIAnimations=' + params.showUIAnimations;
window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
'Beam me down, Scotty!';
console.log('Beam me down, Scotty!');
};
uiUtil.systemAlert(message, 'Warning!', true).then(function (response) {
if (response) {
@ -1018,8 +1009,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
try {
var dummyMessageChannel = new MessageChannel();
if (dummyMessageChannel) return true;
}
catch (e) {
} catch (e) {
return false;
}
return false;
@ -1039,8 +1029,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// DEV: See explanation below for why we access localStorage directly here
var PWASuccessfullyLaunched = localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success';
var allowInternetAccess = settingsStore.getItem('allowInternetAccess') === 'true';
var message = params.defaultModeChangeAlertDisplayed ? '<p>To enable the Service Worker, we ' :
('<p>We shall attempt to switch you to ServiceWorker mode (this is now the default). ' +
var message = params.defaultModeChangeAlertDisplayed
? '<p>To enable the Service Worker, we '
: ('<p>We shall attempt to switch you to ServiceWorker mode (this is now the default). ' +
'It supports more types of ZIM archives and is much more robust.</p><p>We ');
message += 'need one-time access to our secure server so that the app can re-launch as a Progressive Web App (PWA). ' +
'If available, the PWA will work offline, but will auto-update periodically when online as per the ' +
@ -1063,7 +1054,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// regarding the location of the key to be able to retrieve it in init.js before settingsStore is initialized
localStorage.setItem(params.keyPrefix + 'PWA_launch', 'fail');
window.location.href = params.PWAServer + 'www/index.html' + uriParams;
'Beam me up, Scotty!';
console.log('Beam me up, Scotty!');
};
var checkPWAIsOnline = function () {
uiUtil.spinnerDisplay(true, 'Checking server access...');
@ -1148,7 +1139,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} else {
// If DeviceStorage is not available, we display the file select components
displayFileSelect();
if (document.getElementById('archiveFiles').files && document.getElementById('archiveFiles').files.length>0) {
if (document.getElementById('archiveFiles').files && document.getElementById('archiveFiles').files.length > 0) {
// Archive files are already selected,
setLocalArchiveFromFileSelect();
} else {
@ -1169,7 +1160,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
document.getElementById('articleListWithHeader').style.display = 'none';
$('#articleContent').contents().empty();
if (title && !(''===title)) {
if (title && !(title === '')) {
goToArticle(title);
} else if (titleSearch && titleSearch !== '') {
document.getElementById('prefix').value = titleSearch;
@ -1207,7 +1198,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var lastSelectedArchive = settingsStore.getItem('lastSelectedArchive');
if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== '') {
// Attempt to select the corresponding item in the list, if it exists
if ($("#archiveList option[value='"+lastSelectedArchive+"']").length > 0) {
if ($("#archiveList option[value='" + lastSelectedArchive + "']").length > 0) {
document.getElementById('archiveList').value = lastSelectedArchive;
}
}
@ -1236,9 +1227,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var regexpStorageName = /^\/([^/]+)\//;
var regexpResults = regexpStorageName.exec(archiveDirectory);
var selectedStorage = null;
if (regexpResults && regexpResults.length>0) {
if (regexpResults && regexpResults.length > 0) {
var selectedStorageName = regexpResults[1];
for (var i=0; i<storages.length; i++) {
for (var i = 0; i < storages.length; i++) {
var storage = storages[i];
if (selectedStorageName === storage.storageName) {
// We found the selected storage
@ -1255,9 +1246,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
if (storages.length === 1) {
selectedStorage = storages[0];
} else {
uiUtil.systemAlert('Something weird happened with the DeviceStorage API : found a directory without prefix : '
+ archiveDirectory + ', but there were ' + storages.length
+ ' storages found with getDeviceStorages instead of 1', 'Error: unprefixed directory');
uiUtil.systemAlert('Something weird happened with the DeviceStorage API : found a directory without prefix : ' +
archiveDirectory + ', but there were ' + storages.length +
' storages found with getDeviceStorages instead of 1', 'Error: unprefixed directory');
}
}
resetCssCache();
@ -1269,7 +1260,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// callbackError which is called in case of an error
uiUtil.systemAlert(message, label);
});
}
}
@ -1321,7 +1311,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
function handleIframeDrop (e) {
e.stopPropagation();
e.preventDefault();
}
function handleFileDrop (packet) {
@ -1390,7 +1379,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
request.response.name = url;
resolve(request.response);
} else {
reject('HTTP status ' + request.status + ' when reading ' + url);
reject(new Error('HTTP status ' + request.status + ' when reading ' + url));
}
}
};
@ -1444,9 +1433,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
appstate.search.status = 'cancelled';
// Initiate a new search object and point appstate.search to it (the zimArchive search object will continue to point to the old object)
// DEV: Technical explanation: the appstate.search is a pointer to an underlying object assigned in memory, and we are here defining a new object
// in memory {'prefix': prefix, 'status': 'init', .....}, and pointing appstate.search to it; the old search object that was passed to selectedArchive
// in memory {prefix: prefix, status: 'init', .....}, and pointing appstate.search to it; the old search object that was passed to selectedArchive
// (zimArchive.js) continues to exist in the scope of the functions initiated by the previous search until all Promises have returned
appstate.search = {prefix: prefix, status: 'init', type: '', size: params.maxSearchResultsSize};
appstate.search = { prefix: prefix, status: 'init', type: '', size: params.maxSearchResultsSize };
var activeContent = document.getElementById('activeContent');
if (activeContent) activeContent.style.display = 'none';
selectedArchive.findDirEntriesWithPrefix(appstate.search, populateListOfArticles);
@ -1479,7 +1468,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} else if (nbDirEntry >= params.maxSearchResultsSize) {
message = 'First ' + params.maxSearchResultsSize + ' articles found (refine your search).';
} else {
message = 'Finished. ' + (nbDirEntry ? nbDirEntry : 'No') + ' articles found' + (
message = 'Finished. ' + (nbDirEntry || 'No') + ' articles found' + (
reportingSearch.type === 'basic' ? ': try fewer words for full search.' : '.'
);
}
@ -1608,8 +1597,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
if (iframeArticleContent.contentWindow) {
// Configure home key press to focus #prefix only if the feature is in active state
if (params.useHomeKeyToFocusSearchBar)
iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); }
if (params.openExternalLinksInNewTabs) {
// Add event listener to iframe window to check for links to external resources
iframeArticleContent.contentWindow.addEventListener('click', function (event) {
@ -1635,7 +1623,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// remove eventListener to avoid memory leaks
iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
var articleList = document.getElementById('articleList');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
document.getElementById('articleListWithHeader').style.display = 'none';
@ -1792,7 +1780,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
iframeArticleContent.onload = function () {
iframeArticleContent.onload = function () {};
var articleList = document.getElementById('articleList');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
document.getElementById('articleListWithHeader').style.display = 'none';
@ -1800,10 +1788,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var iframeContentDocument = iframeArticleContent.contentDocument;
if (!iframeContentDocument && window.location.protocol === 'file:') {
uiUtil.systemAlert('You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons.'
+ '<br/><br/>The easiest way to run it is to download and run it as a browser extension (from the vendor store).'
+ '<br/><br/>Else you can open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)'
+ "<br/><br/>Another option is to force your browser to accept that (but you'll open a security breach) : on Chrome, you can start it with --allow-file-access-from-files command-line argument; on Firefox, you can set privacy.file_unique_origin to false in about:config");
uiUtil.systemAlert('You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons.' +
'<br/><br/>The easiest way to run it is to download and run it as a browser extension (from the vendor store).' +
'<br/><br/>Else you can open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)' +
"<br/><br/>Another option is to force your browser to accept that (but you'll open a security breach) : on Chrome, you can start it with --allow-file-access-from-files command-line argument; on Firefox, you can set privacy.file_unique_origin to false in about:config");
return;
}
@ -1816,9 +1804,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
docBody = docBody ? docBody[0] : null;
if (docBody) {
// Add any missing classes stripped from the <html> tag
if (htmlCSS) htmlCSS.forEach(function (cl) {
if (htmlCSS) {
htmlCSS.forEach(function (cl) {
docBody.classList.add(cl);
});
}
// Deflect drag-and-drop of ZIM file on the iframe to Config
docBody.addEventListener('dragover', handleIframeDragover);
docBody.addEventListener('drop', handleIframeDrop);
@ -1846,8 +1836,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}
if (iframeArticleContent.contentWindow) {
// Configure home key press to focus #prefix only if the feature is in active state
if (params.useHomeKeyToFocusSearchBar)
iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); }
// when unloaded remove eventListener to avoid memory leaks
iframeArticleContent.contentWindow.onunload = function () {
iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
@ -1998,7 +1987,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
selectedArchive.getDirEntryByPath(url).then(function (dirEntry) {
if (!dirEntry) {
cssCache.set(url, ''); // Prevent repeated lookups of this unfindable asset
throw 'DirEntry ' + typeof dirEntry;
throw new Error('DirEntry ' + typeof dirEntry);
}
var mimetype = dirEntry.getMimetype();
var readFile = /^text\//i.test(mimetype) ? selectedArchive.readUtf8File : selectedArchive.readBinaryFile;
@ -2069,7 +2058,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var source = mediaSource.getAttribute('src');
source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, baseUrl) : null;
// We have to exempt text tracks from using deriveZimUrlFromRelativeurl due to a bug in Firefox [kiwix-js #496]
source = source ? source : decodeURIComponent(mediaSource.dataset.kiwixurl);
source = source || decodeURIComponent(mediaSource.dataset.kiwixurl);
if (!source || !regexpZIMUrlWithNamespace.test(source)) {
if (source) console.error('No usable media source was found for: ' + source);
return;
@ -2100,7 +2089,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
if (params.assetsCache && /\.css$|\.js$/i.test(title)) {
var cacheBlock = document.getElementById('cachingAssets');
cacheBlock.style.display = 'block';
title = title.replace(/[^/]+\//g, '').substring(0,18);
title = title.replace(/[^/]+\//g, '').substring(0, 18);
cacheBlock.textContent = 'Caching ' + title + '...';
}
}
@ -2115,13 +2104,13 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var stateObj = {};
var urlParameters;
var stateLabel;
if (title && !(''===title)) {
if (title && !(title === '')) {
// Prevents creating a double history for the same page
if (history.state && history.state.title === title) return;
stateObj.title = title;
urlParameters = '?title=' + title;
stateLabel = 'Wikipedia Article : ' + title;
} else if (titleSearch && !(''===titleSearch)) {
} else if (titleSearch && !(titleSearch === '')) {
stateObj.titleSearch = titleSearch;
urlParameters = '?titleSearch=' + titleSearch;
stateLabel = 'Wikipedia search : ' + titleSearch;
@ -2131,7 +2120,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
window.history.pushState(stateObj, stateLabel, urlParameters);
}
/**
* Extracts the content of the given article pathname, or a downloadable file, from the ZIM
*
@ -2226,5 +2214,4 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}
});
}
});

View File

@ -1,26 +1,30 @@
/**
* uiUtil.js : Utility functions for the User Interface
*
*
* Copyright 2013-2020 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 <http://www.gnu.org/licenses/>
*/
'use strict';
/* eslint-disable no-global-assign */
/* global $, define, webpMachine, webpHero, params */
// DEV: Put your RequireJS definition in the rqDef array below, and any function exports in the function parenthesis of the define statement
// We need to do it this way in order to load WebP polyfills conditionally. The WebP polyfills are only needed by a few old browsers, so loading them
// only if needed saves approximately 1MB of memory.
@ -31,27 +35,26 @@ if (webpMachine) {
rqDef.push('webpHeroBundle');
}
define(rqDef, function(settingsStore, util) {
define(rqDef, function (settingsStore, util) {
/**
* Displays a Bootstrap alert or confirm dialog box depending on the options provided
*
* @param {String} message The alert message(can be formatted using HTML) to display in the body of the modal.
*
* @param {String} message The alert message(can be formatted using HTML) to display in the body of the modal.
* @param {String} label The modal's label or title which appears in the header (optional, Default = "Confirmation" or "Message")
* @param {Boolean} isConfirm If true, the modal will be a confirm dialog box, otherwise it will be a simple alert message
* @param {String} declineConfirmLabel The text to display on the decline confirmation button (optional, Default = "Cancel")
* @param {Boolean} isConfirm If true, the modal will be a confirm dialog box, otherwise it will be a simple alert message
* @param {String} declineConfirmLabel The text to display on the decline confirmation button (optional, Default = "Cancel")
* @param {String} approveConfirmLabel The text to display on the approve confirmation button (optional, Default = "Confirm")
* @param {String} closeMessageLabel The text to display on the close alert message button (optional, Default = "Okay")
* @returns {Promise<Boolean>} A promise which resolves to true if the user clicked Confirm, false if the user clicked Cancel/Okay, backdrop or the cross(x) button
*/
function systemAlert(message, label, isConfirm, declineConfirmLabel, approveConfirmLabel, closeMessageLabel) {
function systemAlert (message, label, isConfirm, declineConfirmLabel, approveConfirmLabel, closeMessageLabel) {
declineConfirmLabel = declineConfirmLabel || 'Cancel';
approveConfirmLabel = approveConfirmLabel || 'Confirm';
closeMessageLabel = closeMessageLabel || 'Okay';
label = label || (isConfirm ? 'Confirmation' : 'Message');
return util.PromiseQueue.enqueue(function () {
return new Promise(function (resolve, reject) {
if (!message) reject('Missing body message');
if (!message) reject(new Error('Missing body message'));
// Set the text to the modal and its buttons
document.getElementById('approveConfirm').textContent = approveConfirmLabel;
document.getElementById('declineConfirm').textContent = declineConfirmLabel;
@ -86,10 +89,10 @@ define(rqDef, function(settingsStore, util) {
modal.classList.remove('show');
modal.style.display = 'none';
backdrop.classList.remove('show');
if(Array.from(document.body.children).indexOf(backdrop)>=0){
if (Array.from(document.body.children).indexOf(backdrop) >= 0) {
document.body.removeChild(backdrop);
}
//remove event listeners
// remove event listeners
document.getElementById('modalCloseBtn').removeEventListener('click', close);
document.getElementById('declineConfirm').removeEventListener('click', close);
document.getElementById('closeMessage').removeEventListener('click', close);
@ -132,7 +135,7 @@ define(rqDef, function(settingsStore, util) {
document.getElementById('declineConfirm').addEventListener('click', close);
document.getElementById('closeMessage').addEventListener('click', close);
document.getElementById('approveConfirm').addEventListener('click', closeConfirm);
modal.addEventListener('click', close);
document.getElementsByClassName('modal-dialog')[0].addEventListener('click', stopOutsideModalClick);
@ -142,20 +145,20 @@ define(rqDef, function(settingsStore, util) {
});
});
}
/**
* Creates a data: URI from the given content
* The given attribute of the DOM node (nodeAttribute) is then set to this URI
*
*
* This is used to inject images (and other dependencies) into the article DOM
*
*
* @param {Object} node The node to which the URI should be added
* @param {String} nodeAttribute The attribute to set to the URI
* @param {Uint8Array} content The binary content to convert to a URI
* @param {String} mimeType The MIME type of the content
* @param {Function} callback An optional function to call to start processing the next item
*/
function feedNodeWithDataURI(node, nodeAttribute, content, mimeType, callback) {
function feedNodeWithDataURI (node, nodeAttribute, content, mimeType, callback) {
// Decode WebP data if the browser does not support WebP and the mimeType is webp
if (webpMachine && /image\/webp/i.test(mimeType)) {
// If we're dealing with a dataURI, first convert to Uint8Array
@ -200,9 +203,9 @@ define(rqDef, function(settingsStore, util) {
* Determines whether the Canvas Elements Workaround for decoding WebP images is needed, and sets UI accordingly.
* This also sets a global app parameter (useCanvasElementsForWebpTranscoding) that determines whether the workaround will be used in jQuery mode.
* Note that the workaround will never be used in Service Worker mode, but we still need to determine it in case the user switches modes.
* @returns {Boolean} A value to indicate the browser's capability (whether it requires the workaround or not)
* @returns {Boolean} A value to indicate the browser's capability (whether it requires the workaround or not)
*/
function determineCanvasElementsWorkaround() {
function determineCanvasElementsWorkaround () {
var userPreference = settingsStore.getItem('useCanvasElementsForWebpTranscoding') !== 'false';
// Determine whether the browser is able to read canvas data correctly
var browserRequiresWorkaround = webpMachine && webpHero && !webpHero.detectCanvasReadingSupport();
@ -218,18 +221,18 @@ define(rqDef, function(settingsStore, util) {
useCanvasElementsCheck.checked = userPreference;
}
params.useCanvasElementsForWebpTranscoding = browserRequiresWorkaround ? userPreference : false;
// Return the determined browser capability (which may be different from the user's preference) in case caller wants this
// Return the determined browser capability (which may be different from the user's preference) in case caller wants this
return browserRequiresWorkaround;
}
/**
* Replace the given CSS link (from the DOM) with an inline CSS of the given content
*
*
* Due to CSP, Firefox OS does not accept <link> syntax with href="data:text/css..." or href="blob:..."
* So we replace the tag with a <style type="text/css">...</style>
* while copying some attributes of the original tag
* Cf http://jonraasch.com/blog/javascript-style-node
*
*
* @param {Element} link The original link node from the DOM
* @param {String} cssContent The content to insert as an inline stylesheet
*/
@ -251,13 +254,13 @@ define(rqDef, function(settingsStore, util) {
}
link.parentNode.replaceChild(cssElement, link);
}
/**
* Removes parameters and anchors from a URL
* @param {type} url The URL to be processed
* @returns {String} The same URL without its parameters and anchors
*/
function removeUrlParameters(url) {
function removeUrlParameters (url) {
// Remove any querystring
var strippedUrl = url.replace(/\?[^?]*$/, '');
// Remove any anchor parameters - note that we are deliberately excluding entity references, e.g. '&#39;'.
@ -267,13 +270,13 @@ define(rqDef, function(settingsStore, util) {
/**
* Derives the URL.pathname from a relative or semi-relative URL using the given base ZIM URL
*
*
* @param {String} url The (URI-encoded) URL to convert (e.g. "Einstein", "../Einstein",
* "../../I/im%C3%A1gen.png", "-/s/style.css", "/A/Einstein.html", "../static/bootstrap/css/bootstrap.min.css")
* @param {String} base The base ZIM URL of the currently loaded article (e.g. "A/", "A/subdir1/subdir2/", "C/Singapore/")
* @returns {String} The derived ZIM URL in decoded form (e.g. "A/Einstein", "I/imágen.png", "C/")
*/
function deriveZimUrlFromRelativeUrl(url, base) {
function deriveZimUrlFromRelativeUrl (url, base) {
// We use a dummy domain because URL API requires a valid URI
var dummy = 'http://d/';
var deriveZimUrl = function (url, base) {
@ -294,16 +297,16 @@ define(rqDef, function(settingsStore, util) {
* Displays a Bootstrap warning alert with information about how to access content in a ZIM with unsupported active UI
*/
var activeContentWarningSetup = false;
function displayActiveContentWarning() {
function displayActiveContentWarning () {
var alertActiveContent = document.getElementById('activeContent');
alertActiveContent.style.display = '';
if (!activeContentWarningSetup) {
// We are setting up the active content warning for the first time
activeContentWarningSetup = true;
alertActiveContent.querySelector('button[data-hide]').addEventListener('click', function() {
alertActiveContent.querySelector('button[data-hide]').addEventListener('click', function () {
alertActiveContent.style.display = 'none';
});
['swModeLink', 'stop'].forEach(function(id) {
['swModeLink', 'stop'].forEach(function (id) {
// Define event listeners for both hyperlinks in alert box: these take the user to the Config tab and highlight
// the options that the user needs to select
document.getElementById(id).addEventListener('click', function () {
@ -328,7 +331,7 @@ define(rqDef, function(settingsStore, util) {
/**
* Displays a Bootstrap alert box at the foot of the page to enable saving the content of the given title to the device's filesystem
* and initiates download/save process if this is supported by the OS or Browser
*
*
* @param {String} title The path and filename to the file to be extracted
* @param {Boolean|String} download A Bolean value that will trigger download of title, or the filename that should
* be used to save the file in local FS
@ -336,23 +339,25 @@ define(rqDef, function(settingsStore, util) {
* @param {Uint8Array} content The binary-format content of the downloadable file
*/
var downloadAlertSetup = false;
function displayFileDownloadAlert(title, download, contentType, content) {
function displayFileDownloadAlert (title, download, contentType, content) {
var downloadAlert = document.getElementById('downloadAlert');
downloadAlert.style.display = 'block';
if (!downloadAlertSetup) downloadAlert.querySelector('button[data-hide]').addEventListener('click', function() {
// We are setting up the alert for the first time
downloadAlert.style.display = 'none';
});
if (!downloadAlertSetup) {
downloadAlert.querySelector('button[data-hide]').addEventListener('click', function () {
// We are setting up the alert for the first time
downloadAlert.style.display = 'none';
});
}
downloadAlertSetup = true;
// Download code adapted from https://stackoverflow.com/a/19230668/9727685
// Download code adapted from https://stackoverflow.com/a/19230668/9727685
// Set default contentType if none was provided
if (!contentType) contentType = 'application/octet-stream';
var a = document.createElement('a');
var blob = new Blob([content], { 'type': contentType });
var blob = new Blob([content], { type: contentType });
// If the filename to use for saving has not been specified, construct it from title
var filename = download === true ? title.replace(/^.*\/([^\/]+)$/, '$1') : download;
var filename = download === true ? title.replace(/^.*\/([^/]+)$/, '$1') : download;
// Make filename safe
filename = filename.replace(/[\/\\:*?"<>|]/g, '_');
filename = filename.replace(/[/\\:*?"<>|]/g, '_');
a.href = window.URL.createObjectURL(blob);
a.target = '_blank';
a.type = contentType;
@ -360,16 +365,17 @@ define(rqDef, function(settingsStore, util) {
a.classList.add('alert-link');
a.textContent = filename;
var alertMessage = document.getElementById('alertMessage');
//innerHTML required as it has HTML tags
// innerHTML required as it has HTML tags
alertMessage.innerHTML = '<strong>Download</strong> If the download does not start, please tap the following link: ';
// We have to add the anchor to a UI element for Firefox to be able to click it programmatically: see https://stackoverflow.com/a/27280611/9727685
alertMessage.appendChild(a);
try { a.click(); }
catch (err) {
try {
a.click();
} catch (err) {
// If the click fails, user may be able to download by manually clicking the link
// But for IE11 we need to force use of the saveBlob method with the onclick event
// But for IE11 we need to force use of the saveBlob method with the onclick event
if (window.navigator && window.navigator.msSaveBlob) {
a.addEventListener('click', function(e) {
a.addEventListener('click', function (e) {
window.navigator.msSaveBlob(blob, filename);
e.preventDefault();
});
@ -382,7 +388,7 @@ define(rqDef, function(settingsStore, util) {
* Check for update of Service Worker (PWA) and display information to user
*/
var updateAlert = document.getElementById('updateAlert');
function checkUpdateStatus(appstate) {
function checkUpdateStatus (appstate) {
if ('serviceWorker' in navigator && !appstate.pwaUpdateNeeded) {
settingsStore.getCacheNames(function (cacheNames) {
if (cacheNames && !cacheNames.error) {
@ -406,9 +412,11 @@ define(rqDef, function(settingsStore, util) {
});
}
}
if (updateAlert) updateAlert.querySelector('button[data-hide]').addEventListener('click', function () {
updateAlert.style.display = 'none';
});
if (updateAlert) {
updateAlert.querySelector('button[data-hide]').addEventListener('click', function () {
updateAlert.style.display = 'none';
});
}
/**
* Checks if a server is accessible by attempting to load a test image from the server
@ -416,7 +424,7 @@ define(rqDef, function(settingsStore, util) {
* @param {any} onSuccess A function to call if the image can be loaded
* @param {any} onError A function to call if the image cannot be loaded
*/
function checkServerIsAccessible(imageSrc, onSuccess, onError) {
function checkServerIsAccessible (imageSrc, onSuccess, onError) {
var image = new Image();
image.onload = onSuccess;
image.onerror = onError;
@ -425,10 +433,10 @@ define(rqDef, function(settingsStore, util) {
/**
* Show or hide the spinner together with a message
* @param {Boolean} show True to show the spinner, false to hide it
* @param {String} message A message to display, or hide the message if null
* @param {Boolean} show True to show the spinner, false to hide it
* @param {String} message A message to display, or hide the message if null
*/
function spinnerDisplay(show, message) {
function spinnerDisplay (show, message) {
var searchingArticles = document.getElementById('searchingArticles');
var spinnerMessage = document.getElementById('cachingAssets');
if (show) searchingArticles.style.display = 'block';
@ -444,25 +452,26 @@ define(rqDef, function(settingsStore, util) {
/**
* Checks whether an element is partially or fully inside the current viewport
*
*
* @param {Element} el The DOM element for which to check visibility
* @param {Boolean} fully If true, checks that the entire element is inside the viewport;
* @param {Boolean} fully If true, checks that the entire element is inside the viewport;
* if false, checks whether any part of the element is inside the viewport
* @returns {Boolean} True if the element is fully or partially (depending on the value of <fully>)
* inside the current viewport
*/
function isElementInView(el, fully) {
function isElementInView (el, fully) {
var rect = el.getBoundingClientRect();
if (fully)
if (fully) {
return rect.top > 0 && rect.bottom < window.innerHeight && rect.left > 0 && rect.right < window.innerWidth;
else
} else {
return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
}
}
/**
* Removes the animation effect between various sections
*/
function removeAnimationClasses() {
function removeAnimationClasses () {
var configuration = document.getElementById('configuration');
configuration.classList.remove('slideIn_L');
configuration.classList.remove('slideIn_R');
@ -473,15 +482,15 @@ define(rqDef, function(settingsStore, util) {
document.getElementById('articleContent').classList.remove('slideIn_R');
document.getElementById('articleContent').classList.remove('slideOut_L');
}
/**
* Adds the slide animation between different sections
*
*
* @param {String} section It takes the name of the section to which the animation is to be added
*
*
*/
function applyAnimationToSection(section) {
if (section == 'home') {
function applyAnimationToSection (section) {
if (section === 'home') {
if (!$('#configuration').is(':hidden')) {
document.getElementById('configuration').classList.add('slideOut_R');
setTimeout(function () {
@ -498,7 +507,7 @@ define(rqDef, function(settingsStore, util) {
setTimeout(function () {
document.getElementById('articleContent').style.display = '';
}, 300);
} else if (section == 'config') {
} else if (section === 'config') {
if (!$('#about').is(':hidden')) {
$('#about').addClass('slideOut_R');
$('#configuration').addClass('slideIn_R');
@ -515,7 +524,7 @@ define(rqDef, function(settingsStore, util) {
setTimeout(function () {
document.getElementById('configuration').style.display = '';
}, 300);
} else if (section == 'about') {
} else if (section === 'about') {
if (!$('#configuration').is(':hidden')) {
document.getElementById('configuration').classList.add('slideOut_L');
setTimeout(function () {
@ -537,17 +546,17 @@ define(rqDef, function(settingsStore, util) {
/**
* Applies the requested app and content theme
*
*
* A <theme> string consists of two parts, the appTheme (theme to apply to the app shell only), and an optional
* contentTheme beginning with an underscore: e.g. 'dark_invert' = 'dark' (appTheme) + '_invert' (contentTheme)
* Current themes are: light, dark, dark_invert, dark_mwInvert but code below is written for extensibility
* For each appTheme (except the default 'light'), a corresponding set of rules must be present in app.css
* For each contentTheme, a stylesheet must be provided in www/css that is named 'kiwixJS' + contentTheme
* A rule may additionally be needed in app.css for full implementation of contentTheme
*
*
* @param {String} theme The theme to apply (light|dark[_invert|_mwInvert]|auto[_invert|_mwInvert])
*/
function applyAppTheme(theme) {
function applyAppTheme (theme) {
var darkPreference = window.matchMedia('(prefers-color-scheme:dark)');
// Resolve the app theme from the matchMedia preference (for auto themes) or from the theme string
var appTheme = /^auto/.test(theme) ? darkPreference.matches ? 'dark' : 'light' : theme.replace(/_.*$/, '');
@ -583,7 +592,7 @@ define(rqDef, function(settingsStore, util) {
// Hide any previously displayed description for auto themes
var oldDescription = document.getElementById('kiwix-auto-description');
if (oldDescription) oldDescription.style.display = 'none';
// Show description for auto themes
// Show description for auto themes
var description = document.getElementById('kiwix-' + theme.replace(/_.*$/, '') + '-description');
if (description) description.style.display = 'block';
// If there is no ContentTheme or we are applying a different ContentTheme, remove any previously applied ContentTheme
@ -613,21 +622,21 @@ define(rqDef, function(settingsStore, util) {
// If we are in Config and a real document has been loaded already, expose return link so user can see the result of the change
// DEV: The Placeholder string below matches the dummy article.html that is loaded before any articles are loaded
if (document.getElementById('liConfigureNav').classList.contains('active') && doc &&
doc.title !== "Placeholder for injecting an article into the iframe") {
doc.title !== 'Placeholder for injecting an article into the iframe') {
showReturnLink();
}
}
// Displays the return link and handles click event. Called by applyAppTheme()
function showReturnLink() {
function showReturnLink () {
var viewArticle = document.getElementById('viewArticle');
viewArticle.style.display = 'block';
viewArticle.addEventListener('click', function(e) {
viewArticle.addEventListener('click', function (e) {
e.preventDefault();
document.getElementById('liConfigureNav').classList.remove('active');
document.getElementById('liHomeNav').classList.add('active');
removeAnimationClasses();
if (params.showUIAnimations) {
if (params.showUIAnimations) {
applyAnimationToSection('home');
} else {
document.getElementById('configuration').style.display = 'none';
@ -641,7 +650,7 @@ define(rqDef, function(settingsStore, util) {
// Reports an error in loading one of the ASM or WASM machines to the UI API Status Panel
// This can't be done in app.js because the error occurs after the API panel is first displayed
function reportAssemblerErrorToAPIStatusPanel(decoderType, error, assemblerMachineType) {
function reportAssemblerErrorToAPIStatusPanel (decoderType, error, assemblerMachineType) {
console.error('Could not instantiate any ' + decoderType + ' decoder!', error);
params.decompressorAPI.assemblerMachineType = assemblerMachineType;
params.decompressorAPI.errorStatus = 'Error loading ' + decoderType + ' decompressor!';
@ -652,25 +661,25 @@ define(rqDef, function(settingsStore, util) {
}
// Reports the search provider to the API Status Panel
function reportSearchProviderToAPIStatusPanel(provider) {
function reportSearchProviderToAPIStatusPanel (provider) {
var providerAPI = document.getElementById('searchProviderStatus');
if (providerAPI) { // NB we need this so that tests don't fail
providerAPI.textContent = 'Search Provider: ' + (/^fulltext/.test(provider) ? 'Title + Xapian [' + provider + ']' :
/^title/.test(provider) ? 'Title only [' + provider + ']' : 'Not initialized');
providerAPI.textContent = 'Search Provider: ' + (/^fulltext/.test(provider) ? 'Title + Xapian [' + provider + ']'
: /^title/.test(provider) ? 'Title only [' + provider + ']' : 'Not initialized');
providerAPI.className = /^fulltext/.test(provider) ? 'apiAvailable' : !/ERROR/.test(provider) ? 'apiUnavailable' : 'apiBroken';
}
}
// If global variable webpMachine is true (set in init.js), then we need to initialize the WebP Polyfill
if (webpMachine) webpMachine = new webpHero.WebpMachine({useCanvasElements: true});
if (webpMachine) webpMachine = new webpHero.WebpMachine({ useCanvasElements: true });
/**
* Warn the user that he/she clicked on an external link, and open it in a new tab
*
*
* @param {Event} event the click event (on an anchor) to handle
* @param {Element} clickedAnchor the DOM anchor that has been clicked (optional, defaults to event.target)
*/
function warnAndOpenExternalLinkInNewTab(event, clickedAnchor) {
function warnAndOpenExternalLinkInNewTab (event, clickedAnchor) {
event.preventDefault();
event.stopPropagation();
if (!clickedAnchor) clickedAnchor = event.target;
@ -682,21 +691,22 @@ define(rqDef, function(settingsStore, util) {
message += '</p><p style="word-break:break-all;">' + clickedAnchor.href + '</p>';
systemAlert(message, 'Opening external link', true).then(function (response) {
if (response) {
if (!target)
if (!target) {
target = '_blank';
}
window.open(clickedAnchor.href, target);
}
});
}
/**
* Finds the closest <a> or <area> enclosing tag of an element.
* Returns undefined if there isn't any.
*
*
* @param {Element} element
* @returns {Element} closest enclosing anchor tag (if any)
*/
function closestAnchorEnclosingElement(element) {
function closestAnchorEnclosingElement (element) {
if (Element.prototype.closest) {
// Recent browsers support that natively. See https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
return element.closest('a,area');