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) {
// Validate the theme parameter to prevent XSS
// Only allow specific valid theme formats
if (!theme.match(/^(light|dark|auto)(_invert|_mwInvert|_wikiVector)?$/)) {
console.error('Invalid theme format:', theme);
theme = 'light';
}
// Resolve the app theme from the matchMedia preference (for auto themes) or from the theme string
var appTheme = isDarkTheme(theme) ? 'dark' : 'light';
// Get contentTheme from chosen theme
var contentTheme = theme.replace(/^[^_]*/, '');
// Revert to '_invert' or default dark theme if trying to use '_wikiVector' on non-Wikimedia ZIMs
if (contentTheme === '_wikiVector' && !params.isWikimediaZim) {
contentTheme = '_invert';
}
var htmlEl = document.querySelector('html');
var footer = document.querySelector('footer');
var oldTheme = htmlEl.dataset.theme || '';
var iframe = document.getElementById('articleContent');
const library = document.getElementById('libraryContent');
var doc = iframe.contentDocument;
var kiwixJSSheet = doc ? doc.getElementById('kiwixJSTheme') || null : null;
var oldAppTheme = oldTheme.replace(/_.*$/, '');
var oldContentTheme = oldTheme.replace(/^[^_]*/, '');
// Remove oldAppTheme and oldContentTheme
if (oldAppTheme) htmlEl.classList.remove(oldAppTheme);
// A missing contentTheme implies _light
footer.classList.remove(oldContentTheme || '_light');
// Apply new appTheme (NB it will not be added twice if it's already there)
if (appTheme) htmlEl.classList.add(appTheme);
// We also add the contentTheme to the footer to avoid dark css rule being applied to footer when content
// is not dark (but we want it applied when the content is dark or inverted)
footer.classList.add(contentTheme || '_light');
// Embed a reference to applied theme, so we can remove it generically in the future
htmlEl.dataset.theme = appTheme + contentTheme;
// Safely handle help element IDs
var safeOldContentTheme = oldContentTheme.replace(/[^a-zA-Z0-9-]/g, '');
var safeContentTheme = contentTheme.replace(/[^a-zA-Z0-9-]/g, '');
// Hide any previously displayed help
var oldHelp = document.getElementById(safeOldContentTheme.replace(/_/, '') + '-help');
if (oldHelp) oldHelp.style.display = 'none';
// Show any specific help for selected contentTheme
var help = document.getElementById(safeContentTheme.replace(/_/, '') + '-help');
if (help) help.style.display = 'block';
// Remove the contentTheme for auto themes whenever system is in light mode
if (/^auto/.test(theme) && appTheme === 'light') contentTheme = null;
// Hide any previously displayed description for auto themes
var oldDescription = document.getElementById('kiwix-auto-description');
if (oldDescription) oldDescription.style.display = 'none';
// Safely handle description element IDs
var safeThemeBase = theme.replace(/_.*$/, '').replace(/[^a-zA-Z0-9-]/g, '');
// Show description for auto themes
var description = document.getElementById('kiwix-' + safeThemeBase + '-description');
if (description) description.style.display = 'block';
// If there is no ContentTheme or we are applying a different ContentTheme, remove any previously applied ContentTheme
if (oldContentTheme && oldContentTheme !== contentTheme) {
iframe.classList.remove(oldContentTheme);
library.classList.remove(oldContentTheme);
if (kiwixJSSheet) {
kiwixJSSheet.disabled = true;
kiwixJSSheet.parentNode.removeChild(kiwixJSSheet);
}
}
// Apply the requested ContentTheme (if not already attached)
if (contentTheme && (!kiwixJSSheet || !~kiwixJSSheet.href.search('kiwixJS' + contentTheme + '.css'))) {
iframe.classList.add(contentTheme);
library.classList.add(contentTheme);
// Use an absolute reference because Service Worker needs this (if an article loaded in SW mode is in a ZIM
// subdirectory, then relative links injected into the article will not work as expected)
// Note that location.pathname returns the path plus the filename, but is useful because it removes any query string
var prefix = (window.location.protocol + '//' + window.location.host + window.location.pathname).replace(/\/[^/]*$/, '');
if (doc) {
var link = doc.createElement('link');
link.setAttribute('id', 'kiwixJSTheme');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
var safeContentThemeForURL = contentTheme.replace(/[^a-zA-Z0-9_-]/g, '');
link.setAttribute('href', prefix + '/css/kiwixJS' + safeContentThemeForURL + '.css');
doc.head.appendChild(link);
}
}
// 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 &&
// Check if the document contains a meta element with name="description"
!(doc.querySelector('meta[content="Placeholder for injecting an article into the iframe or window"]'))) {
showReturnLink();
}
}
// Determines whether the user has requested a dark theme based on preference and browser settings
function isDarkTheme (theme) {
return /^auto/.test(theme) ? !!window.matchMedia('(prefers-color-scheme:dark)').matches : theme.replace(/_.*$/, '') === 'dark';
}
// Displays the return link and handles click event. Called by applyAppTheme()
function showReturnLink () {
var viewArticle = document.getElementById('viewArticle');
viewArticle.style.display = 'block';
viewArticle.addEventListener('click', returnToCurrentPage);
}
// Function to switch back to currently loaded page
function returnToCurrentPage () {
document.getElementById('liConfigureNav').classList.remove('active');
document.getElementById('liAboutNav').classList.remove('active');
document.getElementById('liHomeNav').classList.add('active');
document.getElementById('btnHome').focus();
var navbarCollapse = document.querySelector('.navbar-collapse');
navbarCollapse.classList.remove('show');
tabTransitionToSection('home', params.showUIAnimations);
const welcomeText = document.getElementById('welcomeText');
welcomeText.style.display = 'none';
document.getElementById('viewArticle').style.display = 'none';
}
// 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) {
console.error('Could not instantiate any ' + decoderType + ' decoder!', error);
params.decompressorAPI.assemblerMachineType = assemblerMachineType;
params.decompressorAPI.errorStatus = (translateUI.t('api-decompressor-error-loading-part1') || 'Error loading') + ' ' + decoderType + ' ' +
(translateUI.t('api-decompressor-error-loading-part2') || 'decompressor!');
var decompAPI = document.getElementById('decompressorAPIStatus');
decompAPI.textContent = (translateUI.t('api-decompressor-label') || 'Decompressor API:') + ' ' + params.decompressorAPI.errorStatus;
decompAPI.className = 'apiBroken';
document.getElementById('apiStatusDiv').className = 'card card-danger';
}
// Reports the search provider to the API Status Panel
function reportSearchProviderToAPIStatusPanel (provider) {
var providerAPI = document.getElementById('searchProviderStatus');
if (providerAPI) { // NB we need this so that tests don't fail
providerAPI.textContent = (translateUI.t('api-searchprovider-label') || 'Search Provider:') + ' ' + (/^fulltext/.test(provider)
? (translateUI.t('api-searchprovider-title') || 'Title') + ' + Xapian [' + provider + ']'
: /^title/.test(provider) ? (translateUI.t('api-searchprovider-titleonly') || 'Title only') + ' [' + provider + ']'
: (translateUI.t('api-error-uninitialized_masculine') || 'Not initialized'));
providerAPI.className = /^fulltext/.test(provider) ? 'apiAvailable' : !/ERROR/.test(provider) ? 'apiUnavailable' : 'apiBroken';
}
}
/**
* Warn the user that they clicked on an external link, and open it in a new tab
*
* @param {Event} event The click event to handle. If not provided, then clickedAnchor must be provided.
* @param {Element} clickedAnchor The DOM anchor that has been clicked (optional, defaults to event.target)
* @param {ZIMArchive} archive The archive object from which the link was scraped (optional)
*/
function warnAndOpenExternalLinkInNewTab (event, clickedAnchor, archive) {
if (event) {
// We have to prevent any blank target from firing on the original event
event.target.removeAttribute('target');
event.preventDefault();
event.stopPropagation();
}
if (!clickedAnchor) clickedAnchor = event.target;
// This is for Zimit-style relative links where the link isn't in the archive, so we have to reconstruct the original URL it was scraped from
if (archive && articleContainer.contentWindow && clickedAnchor.origin === articleContainer.contentWindow.location.origin) {
clickedAnchor.href = clickedAnchor.href.replace(clickedAnchor.origin, archive.source.replace(/\/$/, ''));
}
var target = clickedAnchor.target;
if (!target) {
target = '_blank';
}
let href = clickedAnchor.href;
// @WORKAROUND: Note that for Zimit2 ZIMs (only), any querystring in an external link will be overencoded.
// See https://github.com/kiwix/kiwix-js/issues/1258. DEV: Monitor this issue, and remove the workaround if it is fixed upstream.
if (params.zimType === 'zimit2') {
href = decodeURIComponent(href);
}
if (params.hideExternalLinkWarning) {
window.open(href, target);
return;
}
var message = translateUI.t('dialog-open-externalurl-message') || 'Do you want to open this external link?';
if (target === '_blank') {
message += ' ' + (translateUI.t('dialog-open-externalurl-newtab') || '(in a new tab)');
}
message += '
' + href + '
';
systemAlert(message, translateUI.t('dialog-open-externalurl-title') || 'Opening external link', true, null, null, null, null, true).then(function (response) {
if (response) {
window.open(href, target);
}
});
}
/**
* Finds the closest or enclosing tag of an element.
* Returns undefined if there isn't any.
*
* @param {Element} element The element to test
* @returns {Element} closest enclosing anchor tag (if any)
*/
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');
} else {
// For other browsers, notably IE, we do that by hand (we did not manage to make polyfills work on IE11)
var currentElement = element;
while (currentElement.tagName !== 'A' && currentElement.tagName !== 'AREA') {
// If there is no parent Element, we did not find any enclosing A tag
if (!currentElement.parentElement) {
return;
} else {
// Else we try the next parent Element
currentElement = currentElement.parentElement;
}
}
// If we reach this line, it means the currentElement is the enclosing Anchor we're looking for
return currentElement;
}
}
/**
* Get the base language code that has been set in the browser
* If the browser language is unavailable, the default language is set to British English
*
* @returns {Object} A language object consisting of a base language code and a locale
*/
function getBrowserLanguage () {
// This defines the default language to return if user hasn't selected one
var language = {
base: 'en',
locale: 'GB'
}
var fullLanguage = navigator.language || navigator.userLanguage;
if (fullLanguage) {
language.base = fullLanguage.replace(/-.+$/, '').toLowerCase();
language.locale = fullLanguage.replace(/^[^-]+-/, '').toUpperCase();
}
return language;
}
/**
* Handles the click on the title of an article in search results
* @param {Event} event The click event to handle
* @param {Function} findDirEntryCallback Callback to find and launch article
*/
function handleTitleClick(event, findDirEntryCallback) {
event.preventDefault();
// User may have clicked on a child element of the list item if it contains HTML (for example, italics),
// so we may need to find the closest list item
let target = event.target;
if (!/list-group-item/.test(target.className)) {
console.warn('User clicked on child element of list item, looking for parent...');
while (target && !/list-group-item/.test(target.className)) {
target = target.parentNode;
}
if (!target) {
// No list item found, so we can't do anything
console.warn('No list item could be found for clicked event!');
return;
}
}
var dirEntryId = decodeURIComponent(target.getAttribute('dirEntryId'));
findDirEntryCallback(dirEntryId);
}
/**
* Creates and inserts snippet elements for search results
* @param {Array} entriesArray Array of directory entries
* @param {NodeList} links The article link elements
* @param {Number} length Number of items to process
*/
function createSnippetElements(entriesArray, links, length) {
for (var i = 0; i < length; i++) {
var dirEntry = entriesArray[i];
// Add snippet if it exists
if (dirEntry.snippet && links[i]) {
var snippetId = 'snippet-' + i;
// Create snippet container
var snippetContainer = document.createElement('div');
snippetContainer.className = 'snippet-container';
// Create and populate snippet header
var snippetHeader = document.createElement('div');
snippetHeader.className = 'snippet-header';
snippetHeader.tabIndex = 0;
snippetHeader.setAttribute('data-target', snippetId);
snippetHeader.setAttribute('aria-expanded', 'false');
var indicator = document.createElement('span');
indicator.className = 'snippet-indicator';
indicator.textContent = '▶';
var preview = document.createElement('span');
preview.className = 'snippet-preview';
preview.innerHTML = dirEntry.snippet.substring(0, 80) + '...';
snippetHeader.appendChild(indicator);
snippetHeader.appendChild(preview);
// Create snippet content
var content = document.createElement('div');
content.id = snippetId;
content.className = 'snippet-content collapsed';
content.innerHTML = dirEntry.snippet;
// Assemble and insert
snippetContainer.appendChild(snippetHeader);
snippetContainer.appendChild(content);
// Insert after the article link
links[i].parentNode.insertBefore(snippetContainer, links[i].nextSibling);
}
}
}
/**
* Expands or collapses fulltext search snippet content when the header is selected
* @param {Element} ele The container element that was selected
* @param {Event} ev The event to handle or null if called programmatically
*/
function toggleSnippet (ele, ev) {
if (ev) {
ev.preventDefault();
ev.stopPropagation(); // Prevent triggering the article link
}
var header = ele.children[0]; // Snippet header
var content = ele.children[1]; // Snippet content
var isExpanded = header.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
// Collapse
content.classList.add('collapsed');
header.setAttribute('aria-expanded', 'false');
} else {
// Expand
content.classList.remove('collapsed');
header.setAttribute('aria-expanded', 'true');
}
}
/**
* Attaches event listeners to article list and snippet container elements
* @param {Function} findDirEntryCallback Function to find and launch article by dirEntryId
* @param {Object} appstate App state object containing search status
*/
function attachArticleListEventListeners(findDirEntryCallback, appstate) {
// We have to use mousedown below instead of click as otherwise the prefix blur event fires first
// and prevents this event from firing; note that touch also triggers mousedown
document.querySelectorAll('#articleList a, .snippet-container').forEach(function (element) {
element.addEventListener('mousedown', function (e) {
if (element.classList.contains('snippet-container')) {
// Handle snippet toggle
toggleSnippet(element, e);
} else {
// Handle article link
appstate.search.status = 'cancelled';
handleTitleClick(e, findDirEntryCallback);
}
});
// Add hover functionality for snippet containers with delay
if (element.classList.contains('snippet-container')) {
var hoverTimeout;
element.addEventListener('mouseenter', function () {
// Clear any existing timeout
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
// Set a delay before expanding (e.g., 300ms)
hoverTimeout = setTimeout(function() {
// Safety check: ensure the element still has the expected children
if (element.children.length < 2) return;
var header = element.children[0];
var content = element.children[1];
var isExpanded = header.getAttribute('aria-expanded') === 'true';
// Only expand if not already expanded
if (!isExpanded) {
content.classList.remove('collapsed');
header.setAttribute('aria-expanded', 'true');
}
}, 400);
});
element.addEventListener('mouseleave', function () {
// Clear the timeout if user leaves before delay completes
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
// Safety check: ensure the element still has the expected children
if (element.children.length < 2) return;
// Always collapse on mouse leave
// var header = element.children[0];
// var content = element.children[1];
// content.classList.add('collapsed');
// header.setAttribute('aria-expanded', 'false');
});
}
});
}
/**
* Functions and classes exposed by this module
*/
export default {
hideSlidingUIElements: hideSlidingUIElements,
showSlidingUIElements: showSlidingUIElements,
scroller: scroller,
systemAlert: systemAlert,
feedNodeWithDataURI: feedNodeWithDataURI,
getDataUriFromUint8Array: getDataUriFromUint8Array,
determineCanvasElementsWorkaround: determineCanvasElementsWorkaround,
replaceCSSLinkWithInlineCSS: replaceCSSLinkWithInlineCSS,
deriveZimUrlFromRelativeUrl: deriveZimUrlFromRelativeUrl,
removeUrlParameters: removeUrlParameters,
displayActiveContentWarning: displayActiveContentWarning,
displayFileDownloadAlert: displayFileDownloadAlert,
checkUpdateStatus: checkUpdateStatus,
checkServerIsAccessible: checkServerIsAccessible,
spinnerDisplay: spinnerDisplay,
isElementInView: isElementInView,
removeAnimationClasses: removeAnimationClasses,
tabTransitionToSection: tabTransitionToSection,
applyAppTheme: applyAppTheme,
isDarkTheme: isDarkTheme,
reportAssemblerErrorToAPIStatusPanel: reportAssemblerErrorToAPIStatusPanel,
reportSearchProviderToAPIStatusPanel: reportSearchProviderToAPIStatusPanel,
warnAndOpenExternalLinkInNewTab: warnAndOpenExternalLinkInNewTab,
closestAnchorEnclosingElement: closestAnchorEnclosingElement,
getBrowserLanguage: getBrowserLanguage,
returnToCurrentPage: returnToCurrentPage,
fromSection: fromSection,
handleTitleClick: handleTitleClick,
createSnippetElements: createSnippetElements,
toggleSnippet: toggleSnippet,
attachArticleListEventListeners: attachArticleListEventListeners
};