/*! * app.js : The main Kiwix User Interface implementation * This file handles the interaction between the Kiwix JS back end and the user * * Copyright 2013-2024 Jaifroid, Mossroy and contributors * Licence 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 Licence as published by * the Free Software Foundation, either version 3 of the Licence, 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 Licence for more details. * * You should have received a copy of the GNU General Public Licence * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ 'use strict'; /* eslint-disable indent, eqeqeq */ // import styles from '../css/app.css' assert { type: "css" }; // import bootstrap from '../css/bootstrap.min.css' assert { type: "css" }; import zimArchiveLoader from './lib/zimArchiveLoader.js'; import uiUtil from './lib/uiUtil.js'; import popovers from './lib/popovers.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 resetApp from './lib/resetApp.js'; // Import stylesheets programmatically // document.adoptedStyleSheets = [styles, bootstrap]; /** * Define global state variables: */ // The global parameter and app state objects are defined in init.js /* global params, appstate, assetsCache, nw, electronAPI, Windows, webpMachine, dialog, LaunchParams, launchQueue, abstractFilesystemAccess, MSApp */ // Placeholders for the article container, the article window, the article DOM and some UI elements var articleContainer = document.getElementById('articleContent'); articleContainer.kiwixType = 'iframe'; var articleWindow = articleContainer.contentWindow; var articleDocument; var scrollbox = document.getElementById('scrollbox'); var prefix = document.getElementById('prefix'); // The following variables are used to store the current article and its state var messageChannelWaiting = false; var transformedHTML = ''; var transDirEntry = null; /** * @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(); // A parameter to determine whether the webkitdirectory API is available params['webkitdirectory'] = util.webkitdirectorySupported(); // Retrieve UWP launch arguments when the app is started by double-clicking on a file if (typeof Windows !== 'undefined' && Windows.UI && Windows.UI.WebUI && Windows.UI.WebUI.WebUIApplication) { Windows.UI.WebUI.WebUIApplication.addEventListener('activated', function (eventArgs) { if (eventArgs.kind === Windows.ApplicationModel.Activation.ActivationKind.file) { params.storedFile = eventArgs.files[0].name || ''; if (params.storedFile) { params.pickedFile = eventArgs.files[0]; params.storedFilePath = eventArgs.files[0].path; console.log('App was activated with a file: ' + params.storedFile); processPickedFileUWP(params.pickedFile); } } }, false); } // At launch, we set the correct content injection mode if (params.contentInjectionMode === 'serviceworker' && window.nw) { // Failsafe for Windows XP version: reset app to Restricted mode because it cannot run in SW mode in Windows XP if (nw.process.versions.nw === '0.14.7') setContentInjectionMode('jquery'); } else { setContentInjectionMode(params.contentInjectionMode); } // 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'; } // 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) { // console.debug('Resizing iframe...'); // Re-enable top-level scrolling var configuration = document.getElementById('configuration'); var about = document.getElementById('about'); if (configuration.style.display === 'none' && about.style.display === 'none' && prefix !== document.activeElement) { scrollbox.style.height = 0; } else { scrollbox.style.height = window.innerHeight - document.getElementById('top').getBoundingClientRect().height + 'px'; } uiUtil.showSlidingUIElements(); 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(); console.log('So long, and thanks for all the fish!'); return; } removePageMaxWidth(); checkToolbar(); } 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 val = prefix.value; // Do not initiate the same search if it is already in progress if (appstate.search.prefix === val && !/^(cancelled|complete)$/.test(appstate.search.status)) return; document.getElementById('welcomeText').style.display = 'none'; document.querySelectorAll('.alert').forEach(function (el) { el.style.display = 'none'; }); uiUtil.pollSpinner(); pushBrowserHistoryState(null, val); // Initiate the search searchDirEntriesFromPrefix(val); clearFindInArticle(); // Re-enable top-level scrolling var headerHeight = document.getElementById('top').getBoundingClientRect().height; var footerHeight = document.getElementById('footer').getBoundingClientRect().height; scrollbox.style.height = window.innerHeight - headerHeight - footerHeight + '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.addEventListener('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.addEventListener('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.addEventListener('focus', function () { var val = prefix.value; if (/^\s/.test(val)) { // If user had previously had the archive index open, clear it prefix.value = ''; } else if (val !== '') { document.getElementById('articleListWithHeader').style.display = ''; } scrollbox.style.position = 'absolute'; var headerHeight = document.getElementById('top').getBoundingClientRect().height; var footerHeight = document.getElementById('footer').getBoundingClientRect().height; scrollbox.style.height = window.innerHeight - headerHeight - footerHeight + 'px'; }); // Hide the search results if user moves out of prefix field 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))) { scrollbox.style.height = 0; document.getElementById('articleListWithHeader').style.display = 'none'; appstate.tempPrefix = ''; uiUtil.clearSpinner(); } }, 1); }); // Add keyboard shortcuts window.addEventListener('keyup', function (e) { // 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.key === 'F') { document.getElementById('findText').click(); } }); window.addEventListener('keydown', function (e) { // Ctrl-P to patch printing support, so iframe gets printed if (e.ctrlKey && e.key === 'P') { e.stopPropagation(); e.preventDefault(); printIntercept(); } }, true); // Set up listeners for print dialogues function printArticle (doc) { uiUtil.printCustomElements(doc); 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') { doc.defaultView.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