/*! * app.js : The main Kiwix User Interface implementation * This file handles the interaction between the Kiwix JS back end and the user * * Copyright 2013-2023 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 */ 'use strict'; // import styles from '../css/app.css' assert { type: "css" }; // import bootstrap from '../css/bootstrap.min.css' assert { type: "css" }; import $ from './lib/jquery.module.js'; import zimArchiveLoader from './lib/zimArchiveLoader.js'; import uiUtil from './lib/uiUtil.js'; import util from './lib/util.js'; import utf8 from './lib/utf8.js'; import cache from './lib/cache.js'; import images from './lib/images.js'; import settingsStore from './lib/settingsStore.js'; import transformStyles from './lib/transformStyles.js'; import transformZimit from './lib/transformZimit.js'; import kiwixServe from './lib/kiwixServe.js'; import updater from './lib/updater.js'; // Import stylesheets programmatically // document.adoptedStyleSheets = [styles, bootstrap]; /** * 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; /** * Define global state variables: */ // The global parameter and app state objects are defined in init.js /* global params, appstate, nw */ // Placeholders for the article container, the article window and the article DOM var articleContainer = document.getElementById('articleContent'); articleContainer.kiwixType = 'iframe'; var articleWindow = articleContainer.contentWindow; var articleDocument; /** * @type ZIMArchive */ appstate.selectedArchive = null; // An object to hold the current search and its state (allows cancellation of search across modules) appstate['search'] = { prefix: '', // A field to hold the original search string status: '', // The status of the search: ''|'init'|'interim'|'cancelled'|'complete' type: '' // The type of the search: 'basic'|'full' (set automatically in search algorithm) }; // A parameter to determine the Settings Store API in use (we need to nullify before testing // because params.storeType is also set in a preliminary way in init.js) params['storeType'] = null; params['storeType'] = settingsStore.getBestAvailableStorageAPI(); // Placeholder for the alert box header element, so it can be displayed and hidden easily const alertBoxHeader = document.getElementById('alertBoxHeader'); // Test caching capability cache.test(function(){}); // Unique identifier of the article expected to be displayed appstate.expectedArticleURLToBeDisplayed = ''; // Check if we have managed to switch to PWA mode (if running UWP app) // DEV: we do this in init.js, but sometimes it doesn't seem to register, so we do it again once the app has fully launched if (/UWP\|PWA/.test(params.appType) && /^http/i.test(window.location.protocol)) { // We are in a PWA, so signal success params.localUWPSettings.PWA_launch = 'success'; } // Failsafe for Windows XP version if (params.contentInjectionMode === 'serviceworker' && window.nw) { // Reset app to jQuery mode because it cannot run in SW mode in Windows XP if (nw.process.versions.nw === '0.14.7') setContentInjectionMode('jquery'); } // Make Configuration headings collapsible uiUtil.setupConfigurationToggles(); /** * Resize the IFrame height, so that it fills the whole available height in the window * @param {Boolean} reload Allows reload of the app on resize */ function resizeIFrame(reload) { var scrollbox = document.getElementById('scrollbox'); var header = document.getElementById('top'); var iframe = document.getElementById('articleContent'); var navbarHeight = document.getElementById('navbar').getBoundingClientRect().height; // Reset any hidden headers and footers and iframe shift header.style.zIndex = 1; header.style.transform = 'translateY(0)'; document.getElementById('footer').style.transform = 'translateY(0)'; iframe.style.transform = 'translateY(-1px)'; // iframe.style.height = window.innerHeight + 'px'; // DEV: if we set the iframe with clientHeight, then it takes into account any zoom iframe.style.height = document.documentElement.clientHeight - 5 + 'px'; // This is needed to cause a reflow in Zimit ZIMs setTimeout(function() { iframe.style.height = document.documentElement.clientHeight + 'px'; }, 5); // Re-enable top-level scrolling scrollbox.style.height = window.innerHeight - navbarHeight + 'px'; if (iframe.style.display !== "none" && document.getElementById("prefix") !== document.activeElement) { scrollbox.style.height = 0; } var ToCList = document.getElementById('ToCList'); if (typeof ToCList !== "undefined") { ToCList.style.maxHeight = ~~(window.innerHeight * 0.75) + 'px'; ToCList.style.marginLeft = ~~(window.innerWidth / 2) - ~~(window.innerWidth * 0.16) + 'px'; } if (window.outerWidth <= 470) { document.getElementById('dropup').classList.remove('col-xs-4'); document.getElementById('dropup').classList.add('col-xs-3'); if (window.outerWidth <= 360) { document.getElementById('btnTop').classList.remove('col-xs-2'); document.getElementById('btnTop').classList.add('col-xs-1'); } else { document.getElementById('btnTop').classList.remove('col-xs-1'); document.getElementById('btnTop').classList.add('col-xs-2'); } } else { document.getElementById('dropup').classList.remove('col-xs-3'); document.getElementById('dropup').classList.add('col-xs-4'); } if (settingsStore.getItem('reloadDispatched') === 'true') { setTimeout(function () { settingsStore.removeItem('reloadDispatched'); }, 1000); } else if (reload && params.resetDisplayOnResize) { settingsStore.setItem('reloadDispatched', true, Infinity); window.location.reload(); throw 'So long, and thanks for all the fish!'; } removePageMaxWidth(); checkToolbar(); } document.onDOMContentLoaded = resizeIFrame; window.onresize = function () { resizeIFrame(true); // Check whether fullscreen icon needs to be updated setDynamicIcons(); // We need to load any images exposed by the resize var scrollFunc = document.getElementById('articleContent').contentWindow; scrollFunc = scrollFunc ? scrollFunc.onscroll : null; if (scrollFunc) scrollFunc(); }; // Define behavior of HTML elements if (params.navButtonsPos === 'top') { // User has requested navigation buttons should be at top, so we need to swap them var btnBack = document.getElementById('btnBack'); var btnBackAlt = document.getElementById('btnBackAlt'); btnBack.id = 'btnBackAlt'; btnBackAlt.id = 'btnBack'; btnBackAlt.style.display = 'inline'; btnBack.style.display = 'none'; var btnForward = document.getElementById('btnForward'); var btnForwardAlt = document.getElementById('btnForwardAlt'); btnForward.id = 'btnForwardAlt'; btnForwardAlt.id = 'btnForward'; btnForwardAlt.style.display = 'inline'; btnForward.style.display = 'none'; var btnRandom = document.getElementById('btnRandomArticle'); var btnRandomAlt = document.getElementById('btnRandomArticleAlt'); btnRandom.id = 'btnRandomArticleAlt'; btnRandomAlt.id = 'btnRandomArticle'; btnRandom.style.display = 'none'; btnRandomAlt.style.display = 'inline'; } // Process pointerup events (used for checking if mouse back / forward buttons have been clicked) function onPointerUp(e) { if (typeof e === 'object') { if (e.button === 3) { document.getElementById('btnBack').click(); } if (e.button === 4) { document.getElementById('btnForward').click(); } } } if (/UWP/.test(params.appType)) document.body.addEventListener('pointerup', onPointerUp); var searchArticlesFocused = false; document.getElementById('searchArticles').addEventListener('click', function () { var prefix = document.getElementById('prefix').value; // Do not initiate the same search if it is already in progress if (appstate.search.prefix === prefix && !/^(cancelled|complete)$/.test(appstate.search.status)) return; document.getElementById('welcomeText').style.display = 'none'; $('.alert').hide(); uiUtil.pollSpinner(); pushBrowserHistoryState(null, prefix); // Initiate the search searchDirEntriesFromPrefix(prefix); clearFindInArticle(); //Re-enable top-level scrolling document.getElementById('scrollbox').style.height = window.innerHeight - document.getElementById('top').getBoundingClientRect().height + 'px'; // This flag is set to true in the mousedown event below searchArticlesFocused = false; }); document.getElementById('formArticleSearch').addEventListener('submit', function () { document.getElementById('searchArticles').click(); }); // Handle keyboard events in the prefix (article search) field var keyPressHandled = false; $('#prefix').on('keydown', function (e) { // If user presses Escape... // IE11 returns "Esc" and the other browsers "Escape"; regex below matches both if (/^Esc/.test(e.key)) { // Hide the article list e.preventDefault(); e.stopPropagation(); document.getElementById('articleListWithHeader').style.display = 'none'; document.getElementById('articleContent').focus(); document.getElementById('mycloseMessage').click(); // This is in case the modal box is showing with an index search keyPressHandled = true; } // Arrow-key selection code adapted from https://stackoverflow.com/a/14747926/9727685 // IE11 produces "Down" instead of "ArrowDown" and "Up" instead of "ArrowUp" if (/^((Arrow)?Down|(Arrow)?Up|Enter)$/.test(e.key)) { // User pressed Down arrow or Up arrow or Enter e.preventDefault(); e.stopPropagation(); // This is needed to prevent processing in the keyup event : https://stackoverflow.com/questions/9951274 keyPressHandled = true; var activeElement = document.querySelector("#articleList .hover") || document.querySelector("#articleList a"); if (!activeElement) return; // If user presses Enter, read the dirEntry if (/Enter/.test(e.key)) { if (activeElement.classList.contains('hover')) { var dirEntryId = activeElement.getAttribute('dirEntryId'); findDirEntryFromDirEntryIdAndLaunchArticleRead(decodeURIComponent(dirEntryId)); return; } } // If user presses ArrowDown... // (NB selection is limited to five possibilities by regex above) if (/Down/.test(e.key)) { if (activeElement.classList.contains('hover')) { activeElement.classList.remove('hover'); activeElement = activeElement.nextElementSibling || activeElement; var nextElement = activeElement.nextElementSibling || activeElement; if (!uiUtil.isElementInView(window, nextElement, true)) nextElement.scrollIntoView(false); } } // If user presses ArrowUp... if (/Up/.test(e.key)) { activeElement.classList.remove('hover'); activeElement = activeElement.previousElementSibling || activeElement; var previousElement = activeElement.previousElementSibling || activeElement; if (!uiUtil.isElementInView(window, previousElement, true)) previousElement.scrollIntoView(); if (previousElement === activeElement) { document.getElementById('articleListWithHeader').scrollIntoView(); document.getElementById('top').scrollIntoView(); } } activeElement.classList.add('hover'); } }); // Search for titles as user types characters $('#prefix').on('keyup', function (e) { if (appstate.selectedArchive !== null && appstate.selectedArchive.isReady()) { // Prevent processing by keyup event if we already handled the keypress in keydown event if (keyPressHandled) keyPressHandled = false; else onKeyUpPrefix(e); } }); // Restore the search results if user goes back into prefix field $('#prefix').on('focus', function (e) { var prefixVal = $('#prefix').val(); if (/^\s/.test(prefixVal)) { // If user had previously had the archive index open, clear it document.getElementById('prefix').value = ''; } else if (prefixVal !== '') { document.getElementById('articleListWithHeader').style.display = ''; } document.getElementById('scrollbox').style.position = 'absolute'; document.getElementById('scrollbox').style.height = window.innerHeight - document.getElementById('top').getBoundingClientRect().height + 'px'; }); // Hide the search results if user moves out of prefix field document.getElementById('prefix').addEventListener('blur', function () { if (!searchArticlesFocused) { appstate.search.status = 'cancelled'; } // We need to wait one tick for the activeElement to receive focus setTimeout(function () { if (!(/^articleList|searchSyntaxLink/.test(document.activeElement.id) || /^list-group/.test(document.activeElement.className))) { document.getElementById('scrollbox').style.height = 0; document.getElementById('articleListWithHeader').style.display = 'none'; appstate.tempPrefix = ''; uiUtil.clearSpinner(); } }, 1); }); //Add keyboard shortcuts window.addEventListener('keyup', function (e) { e = e || window.event; //Alt-F for search in article, also patches Ctrl-F for apps that do not have access to browser search if ((e.ctrlKey || e.altKey) && e.which == 70) { document.getElementById('findText').click(); } }); window.addEventListener('keydown', function (e) { //Ctrl-P to patch printing support, so iframe gets printed if (e.ctrlKey && e.which == 80) { e.stopPropagation(); e.preventDefault(); printIntercept(); } }, true); //Set up listeners for print dialogues function printArticle() { uiUtil.printCustomElements(); uiUtil.systemAlert('Document will now reload to restore the DOM after printing...').then(function () { printCleanup(); }); //innerDocument.execCommand("print", false, null); // if (typeof window.nw !== 'undefined' || typeof window.fs === 'undefined') { window.frames[0].frameElement.contentWindow.print(); // } else { // // We are in an Electron app and need to use export to browser to print // params.preloadingAllImages = false; // // Add a window.print() script to the html // document.getElementById('articleContent').contentDocument.head.innerHTML += // '\n