From bf4042bb6f15ed3a2107f2813d5d43e4732d7f76 Mon Sep 17 00:00:00 2001 From: Jaifroid Date: Sun, 2 Jun 2024 15:40:14 +0100 Subject: [PATCH] Add popover previews of Wikimedia article links #719 (#1252) --- i18n/en.jsonp.js | 7 +- i18n/es.jsonp.js | 7 +- i18n/fr.jsonp.js | 7 +- service-worker.js | 3 + tests/e2e/spec/legacy-ray_charles.e2e.spec.js | 32 +- www/img/icons/new_window_black.svg | 8 + www/img/icons/new_window_white.svg | 8 + www/index.html | 17 +- www/js/app.js | 194 ++++++- www/js/init.js | 5 +- www/js/lib/popovers.js | 482 ++++++++++++++++++ www/js/lib/uiUtil.js | 17 +- 12 files changed, 743 insertions(+), 44 deletions(-) create mode 100644 www/img/icons/new_window_black.svg create mode 100644 www/img/icons/new_window_white.svg create mode 100644 www/js/lib/popovers.js diff --git a/i18n/en.jsonp.js b/i18n/en.jsonp.js index 4663f7ba9..65733e9f5 100644 --- a/i18n/en.jsonp.js +++ b/i18n/en.jsonp.js @@ -66,8 +66,8 @@ document.localeJson = { "configure-display-apptheme-info": "[ Show article with applied theme ]", "configure-language-selector-default": "Language", "configure-language-selector-other": "More soon...", - "configure-performance-settings-title": "Performance settings", - "configure-performance-panel-header": "Speed up archive access", + "configure-performance-settings-title": "Performance / compatibility", + "configure-performance-panel-header": "Caching and preview settings", "configure-performance-cacheassets-description": "Kiwix JS can speed up the display of articles by caching assets:", "configure-performance-cacheassets-true": "Cache assets (recommended)", "configure-performance-cacheassets-true-tip": "The cache provides high-speed access to ZIM assets such as stylesheets and scripts, which will improve article load times.", @@ -79,7 +79,8 @@ document.localeJson = { "configure-performance-searchrange-label": "Select max number of search results:", "configure-performance-searchrange-valuetext": "Value", "configure-performance-searchrange-help": "default 25, higher values increase search time", - "configure-compatibility-settings-title": "Compatibility settings", + "configure-previews": "Show a popover preview of Wikipedia / Wkivoyage articles when hovering over links (limited functionality in Safe Mode)", + "configure-previews-tip": "Shows a small popup preview of Wikipedia and Wikivoyage articles when the pointer is hovered over an article link. Turn this off if it is too slow or interferes wtih display of articles on small-screen devices.", "configure-compatibility-panel-header": "Content injection mode", "configure-compatibility-see": "See", "configure-compatibility-modeslink": "About (Technical Information)", diff --git a/i18n/es.jsonp.js b/i18n/es.jsonp.js index 739a6e079..ad9b11f32 100644 --- a/i18n/es.jsonp.js +++ b/i18n/es.jsonp.js @@ -66,8 +66,8 @@ document.localeJson = { "configure-display-apptheme-info": "[ Mostrar artículo con tema seleccionado ]", "configure-language-selector-default": "Idioma", "configure-language-selector-other": "Más pronto...", - "configure-performance-settings-title": "Mejorar rendimiento", - "configure-performance-panel-header": "Acelerar acceso al archivo", + "configure-performance-settings-title": "Rendimiento y compatibilidad", + "configure-performance-panel-header": "Ajustes de caché y vista previa", "configure-performance-cacheassets-description": "Kiwix JS puede acelerar la visualización de artículos almacenando en caché los activos:", "configure-performance-cacheassets-true": "Guardar activos en caché (recomendado)", "configure-performance-cacheassets-true-tip": "La caché proporciona acceso de alta velocidad a los activos en el ZIM, como las hojas de estilo y los scripts. Esto puede reducir el tiempo de espera para cargar los artículos.", @@ -79,7 +79,8 @@ document.localeJson = { "configure-performance-searchrange-label": "Número máximo de resultados de búsqueda:", "configure-performance-searchrange-valuetext": "Valor actual", "configure-performance-searchrange-help": "por defecto 25, valores más altos aumentan el tiempo de búsqueda", - "configure-compatibility-settings-title": "Opciones de compatibilidiad", + "configure-previews": "Mostrar vista previa de artículos de Wikipedia / Wikivoyage al pasar el cursor sobre los enlaces (funcionalidad limitada en Modo Seguro)", + "configure-previews-tip": "Muestra una pequeña vista previa emergente de los artículos de Wikipedia y Wikivoyage cuando el puntero se pasa sobre un enlace de artículo. Desactivar si es demasiado lento o interfiere con la visualización de artículos en dispositivos de pantalla pequeña.", "configure-compatibility-panel-header": "Modo de inyección de contenido", "configure-compatibility-see": "Ver", "configure-compatibility-modeslink": "Información técnica", diff --git a/i18n/fr.jsonp.js b/i18n/fr.jsonp.js index ac0d10d52..1abea61de 100644 --- a/i18n/fr.jsonp.js +++ b/i18n/fr.jsonp.js @@ -66,8 +66,8 @@ document.localeJson = { "configure-display-apptheme-info": "[ Afficher l'article avec le thème sélectionné ]", "configure-language-selector-default": "Langue", "configure-language-selector-other": "Bientôt plus...", - "configure-performance-settings-title": "Améliorer les performances", - "configure-performance-panel-header": "Accélérer l'accès au fichier", + "configure-performance-settings-title": "Performances et compatibilité", + "configure-performance-panel-header": "Mise en caché et prévisualisation", "configure-performance-cacheassets-description": "Kiwix JS peut accélérer l'affichage des articles en mettant en cache les ressources :", "configure-performance-cacheassets-true": "Mettre en cache les ressources (recommandé)", "configure-performance-cacheassets-true-tip": "Le cache permet un accès à haute vitesse aux ressources du ZIM, telles que les feuilles de style et les scripts. Cela peut réduire le temps d'attente pour le chargement des articles.", @@ -79,7 +79,8 @@ document.localeJson = { "configure-performance-searchrange-label": "Nombre maximal de résultats de recherche :", "configure-performance-searchrange-valuetext": "Valeur actuelle", "configure-performance-searchrange-help": "par défaut 25, des valeurs plus élevées augmentent le temps de recherche", - "configure-compatibility-settings-title": "Options de compatibilité", + "configure-previews": "Afficher un aperçu des articles Wikipédia / Wikivoyage en survolant les liens (fonctionnalité limitée en Mode Sécurisé)", + "configure-previews-tip": "Affiche un petit aperçu en popup des articles de Wikipédia et Wikivoyage lorsque le pointeur est placé sur le lien d'article. Désactivez cette fonction si elle est trop lente ou si elle interfère avec l'affichage des articles sur les appareils à petit écran.", "configure-compatibility-panel-header": "Mode d'injection de contenu", "configure-compatibility-see": "Voir", "configure-compatibility-modeslink": "Informations techniques", diff --git a/service-worker.js b/service-worker.js index 5432d734d..be87083eb 100644 --- a/service-worker.js +++ b/service-worker.js @@ -122,6 +122,8 @@ const precacheFiles = [ 'www/img/icons/kiwix-256.png', 'www/img/icons/kiwix-32.png', 'www/img/icons/kiwix-60.png', + 'www/img/icons/new_window_black.svg', + 'www/img/icons/new_window_white.svg', 'www/img/spinner.gif', 'www/img/Icon_External_Link.png', 'www/index.html', @@ -135,6 +137,7 @@ const precacheFiles = [ 'www/js/lib/arrayFromPolyfill.js', 'www/js/lib/filecache.js', 'www/js/lib/cache.js', + 'www/js/lib/popovers.js', 'www/js/lib/promisePolyfill.js', 'www/js/lib/settingsStore.js', 'www/js/lib/translateUI.js', diff --git a/tests/e2e/spec/legacy-ray_charles.e2e.spec.js b/tests/e2e/spec/legacy-ray_charles.e2e.spec.js index b0bfcbd41..783ba34a1 100644 --- a/tests/e2e/spec/legacy-ray_charles.e2e.spec.js +++ b/tests/e2e/spec/legacy-ray_charles.e2e.spec.js @@ -205,7 +205,7 @@ function runTests (driver, modes) { it('Load legacy Ray Charles and check index contains specified article', async function () { if (!serviceWorkerAPI) { console.log('\x1b[33m%s\x1b[0m', ' - Following test skipped:'); - return; + this.skip(); } const archiveFiles = await driver.findElement(By.id('archiveFiles')); // Unhide the element using JavaScript in case it is hidden @@ -234,7 +234,7 @@ function runTests (driver, modes) { it('Navigate to "This Little Girl of Mine"', async function () { if (!serviceWorkerAPI) { console.log('\x1b[33m%s\x1b[0m', ' - Following test skipped:'); - return; + this.skip(); } // console.log('FilesLength outer: ' + filesLength); @@ -274,12 +274,38 @@ function runTests (driver, modes) { // console.log('Element text: ' + elementText); // Check that the article title is correct assert.equal('Instrumentation by the Ray Charles Orchestra', elementText); + await driver.switchTo().defaultContent(); + }); + + it('Check for popover functionality when focusing link', async function () { + // Check if the browser supports 'matches' in Element.prototype + const matchesSupported = await driver.executeScript('return typeof Element.prototype.matches === "function";'); + if (!matchesSupported) { + console.log('\x1b[33m%s\x1b[0m', ' - Following test skipped because browser does not support css matches:'); + this.skip(); + } + // Switch to iframe + await driver.switchTo().frame('articleContent'); + // Focus on the link "Hallelujah" with id="mwVw" + let link = await driver.findElement(By.id('mwVw')); + await driver.executeScript('arguments[0].focus();', link); + await driver.sleep(2000); + // Focus on the next link "A Fool for You" with id="mwWw" + await driver.executeScript('document.getElementById("mwWw").focus();'); + // Wait for the popover to appear + await driver.sleep(2500); // DEV: Adjust this delay if failing on older, slower browsers + // Use standard JavaScript methods to find the popover element because Safari 14 fails when using WebDriver methods + let popover = await driver.executeScript('return document.querySelector(".kiwixtooltip").outerHTML;'); + // The popover should contain the word "bluesy" (description of style of song) + let popoverContainsText = /bluesy/.test(popover); + assert.ok(popoverContainsText, 'Popover div with class ".kiwixtooltip" did not have expected text "bluesy"'); + await driver.switchTo().defaultContent(); }); it('Search for Ray Charles in title index and go to article', async function () { if (!serviceWorkerAPI) { console.log('\x1b[33m%s\x1b[0m', ' - Following test skipped:'); - return; + this.skip(); } await driver.switchTo().defaultContent(); const prefix = await driver.findElement(By.id('prefix')); diff --git a/www/img/icons/new_window_black.svg b/www/img/icons/new_window_black.svg new file mode 100644 index 000000000..46848203f --- /dev/null +++ b/www/img/icons/new_window_black.svg @@ -0,0 +1,8 @@ + + + + new window + + + + diff --git a/www/img/icons/new_window_white.svg b/www/img/icons/new_window_white.svg new file mode 100644 index 000000000..17b07f97b --- /dev/null +++ b/www/img/icons/new_window_white.svg @@ -0,0 +1,8 @@ + + + + new window + + + + diff --git a/www/index.html b/www/index.html index e25b8642a..f0c4917f0 100644 --- a/www/index.html +++ b/www/index.html @@ -577,9 +577,9 @@

Display settings

-

Performance settings

+

Performance / compatibility

-
Speed up archive access
+
Caching and preview settings
@@ -628,11 +628,22 @@

Performance settings

+
+
+
+ +
+
+
-

Compatibility settings

Content injection mode
diff --git a/www/js/app.js b/www/js/app.js index 03ab1aea9..4e26ba954 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -31,6 +31,7 @@ import '../../node_modules/@fortawesome/fontawesome-free/js/all.js'; import zimArchiveLoader from './lib/zimArchiveLoader.js'; import uiUtil from './lib/uiUtil.js'; +import popovers from './lib/popovers.js'; import settingsStore from './lib/settingsStore.js'; import abstractFilesystemAccess from './lib/abstractFilesystemAccess.js'; import translateUI from './lib/translateUI.js'; @@ -105,7 +106,7 @@ const folderSelect = document.getElementById('folderSelect'); const archiveFiles = document.getElementById('archiveFiles'); // Unique identifier of the article expected to be displayed -var expectedArticleURLToBeDisplayed = ''; +appstate.expectedArticleURLToBeDisplayed = ''; // define and store dark preference for matchMedia var darkPreference = window.matchMedia('(prefers-color-scheme:dark)'); @@ -605,6 +606,10 @@ document.getElementById('titleSearchRange').addEventListener('change', function document.getElementById('titleSearchRange').addEventListener('input', function (e) { titleSearchRangeVal.textContent = e.target.value; }); +document.getElementById('showPopoverPreviewsCheck').addEventListener('change', function (e) { + params.showPopoverPreviews = e.target.checked; + settingsStore.setItem('showPopoverPreviews', params.showPopoverPreviews, Infinity); +}); // Add event listeners to the About links in Configuration, so that they jump to the linked sections document.querySelectorAll('.aboutLinks').forEach(function (link) { link.addEventListener('click', function () { @@ -1839,6 +1844,8 @@ async function archiveReadyCallback (archive) { params.originalContentInjectionMode = null; } } + // This flag will be reset each time a new archive is loaded + appstate.wikimediaZimLoaded = /wikipedia|wikivoyage|mdwiki|wiktionary/i.test(archive.file.name); // Set contentInjectionMode to serviceWorker when opening a new archive in case the user switched to Safe Mode/jquery Mode when opening the previous archive if (params.contentInjectionMode === 'jquery') { params.contentInjectionMode = settingsStore.getItem('contentInjectionMode'); @@ -2062,15 +2069,15 @@ function findDirEntryFromDirEntryIdAndLaunchArticleRead (dirEntryId) { } /** - * Check whether the given URL from given dirEntry equals the expectedArticleURLToBeDisplayed + * Check whether the given URL from given dirEntry matches the expected article * @param {DirEntry} dirEntry The directory entry of the article to read */ function isDirEntryExpectedToBeDisplayed (dirEntry) { var curArticleURL = dirEntry.namespace + '/' + dirEntry.url; - if (expectedArticleURLToBeDisplayed !== curArticleURL) { + if (appstate.expectedArticleURLToBeDisplayed !== curArticleURL) { console.debug('url of current article :' + curArticleURL + ', does not match the expected url :' + - expectedArticleURLToBeDisplayed); + appstate.expectedArticleURLToBeDisplayed); return false; } return true; @@ -2089,8 +2096,10 @@ function readArticle (dirEntry) { // Reset search prefix to allow users to search the same string again if they want to appstate.search.prefix = ''; - // Only update for expectedArticleURLToBeDisplayed. - expectedArticleURLToBeDisplayed = dirEntry.namespace + '/' + dirEntry.url; + // Only update for appstate.expectedArticleURLToBeDisplayed. + appstate.expectedArticleURLToBeDisplayed = dirEntry.namespace + '/' + dirEntry.url; + // Calculate the current article's ZIM baseUrl to use when processing relative links + appstate.baseUrl = encodeURI(dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '')); // We must remove focus from UI elements in order to deselect whichever one was clicked (in both jQuery and SW modes), // but we should not do this when opening the landing page (or else one of the Unit Tests fails, at least on Chrome 58) if (!params.isLandingPage) articleContainer.contentWindow.focus(); @@ -2211,7 +2220,12 @@ function filterClickEvent (event) { clickedAnchor.passthrough = false; return; } + // Remove any Kiwix Popovers that may be hanging around + popovers.removeKiwixPopoverDivs(event.target.ownerDocument); + if (params.contentInjectionMode === 'jquery' || !params.openExternalLinksInNewTabs && !clickedAnchor.newcontainer) return; if (clickedAnchor) { + // This prevents any popover from being displayed when the user clicks on a link + clickedAnchor.articleisloading = true; // Check for Zimit links that would normally be handled by the Replay Worker // DEV: '__WB_pmw' is a function inserted by wombat.js, so this detects links that have been rewritten in zimit2 archives // however, this misses zimit2 archives where the framework doesn't support wombat.js, so monitor if always processing zimit2 links @@ -2220,6 +2234,7 @@ function filterClickEvent (event) { articleWindow.location.href.replace(/[#?].*$/, '') !== clickedAnchor.href.replace(/[#?].*$/, '') && !clickedAnchor.hash) { return handleClickOnReplayLink(event, clickedAnchor); } + // DEV: The href returned below is the href as written in the HTML, which may be relative var href = clickedAnchor.getAttribute('href'); // We assume that, if an absolute http(s) link is hardcoded inside an HTML string, it means it's a link to an external website. // We also do it for ftp even if it's not supported any more by recent browsers... @@ -2227,14 +2242,15 @@ function filterClickEvent (event) { console.debug('filterClickEvent opening external link in new tab'); clickedAnchor.newcontainer = true; uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor); - } else if (/\.pdf([?#]|$)/i.test(href) && selectedArchive.zimType !== 'zimit') { - // Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab + } else if (clickedAnchor.newcontainer || /\.pdf([?#]|$)/i.test(href) && selectedArchive.zimType !== 'zimit') { + // Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab. We also open + // a new tab if the user has explicitly requested it: in this case the anchor will have a property 'newcontainer' (e.g. with popover control) event.preventDefault(); event.stopPropagation(); - console.debug('filterClickEvent opening new window for PDF'); + console.debug('filterClickEvent opening new window for PDF or requested new container'); clickedAnchor.newcontainer = true; window.open(clickedAnchor.href, '_blank'); - } else if (/\/[-ABCIJMUVWX]\/.+$/.test(clickedAnchor.href)) { + } else if (/\/[-ABCIJMUVWX]\/.+$/.test(clickedAnchor.href)) { // clickedAnchor.href returns the absolute URL, including any namespace // Show the spinner if it's a ZIM link, but not an anchor if (!~href.indexOf('#')) { var message = href.match(/(?:^|\/)([^/]{1,13})[^/]*?$/); @@ -2247,6 +2263,14 @@ function filterClickEvent (event) { uiUtil.showSlidingUIElements(); } } + // Reset popup block + setTimeout(function () { + // Anchor may have been unloaded along with the page by the time this runs + // but will still be present if user opened a new tab + if (clickedAnchor) { + clickedAnchor.articleisloading = false; + } + }, 1000); } }; @@ -2276,13 +2300,13 @@ function articleLoadedSW (iframeArticleContent) { } resizeIFrame(); - if (iframeArticleContent.contentWindow) { + var iframeWindow = iframeArticleContent.contentWindow; + if (iframeWindow) { // Configure home key press to focus #prefix only if the feature is in active state - if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.onkeydown = focusPrefixOnHomeKey; } - if (params.openExternalLinksInNewTabs) { - // Add event listener to iframe window to check for links to external resources - iframeArticleContent.contentWindow.onclick = filterClickEvent; - } + if (params.useHomeKeyToFocusSearchBar) { iframeWindow.onkeydown = focusPrefixOnHomeKey; } + // Add event listeners to iframe window to check for links to external resources and for actions that trigger popovers + iframeWindow.onclick = filterClickEvent; + attachPopoverTriggerEvents(iframeWindow); // If we are in a zimit2 ZIM and params.serviceWorkerLocal is true, and it's a landing page, then we should display a warning if (!params.hideActiveContentWarning && params.isLandingPage && params.zimType === 'zimit2' && params.serviceWorkerLocal) { uiUtil.displayActiveContentWarning('ServiceWorkerLocal'); @@ -2303,6 +2327,117 @@ function articleLoadedSW (iframeArticleContent) { params.isLandingPage = false; }; +/** + * Attaches popover trigger events to the given window + * @param {Window} win The window to which to attach popover trigger events + */ +function attachPopoverTriggerEvents (win) { + const iframeDoc = win.document; + // The popover feature requires as a minimum that the browser supports the css matches function + // (having this condition prevents very erratic popover placement in IE11, for example, so the feature is disabled for such browsers) + if (!iframeDoc || !appstate.wikimediaZimLoaded || !params.showPopoverPreviews || !('matches' in Element.prototype)) { + return; + } + // Attach the popover CSS to the current article document + popovers.attachKiwixPopoverCss(iframeDoc); + // Add event listeners to the iframe window to check when anchors are hovered, focused or touched + win.addEventListener('mouseover', evokePopoverEvents, true); + win.addEventListener('focus', evokePopoverEvents, true); + // Conditionally add event listeners to support touch events with fallback to pointer events + if (window.navigator.maxTouchPoints > 0) { + win.addEventListener('touchstart', evokePopoverEvents, true); + } else { + win.addEventListener('pointerdown', evokePopoverEvents, true); + } +} + +// Throttle for the popover event handler to prevent multiple activations with mouse movement +let popoverThrottle = false; + +/** + * Conditionally evokes popover events subject to a throttle + * @param {Event} event The event produced by the calling action + */ +function evokePopoverEvents (event) { + // Check if the hovered or focused element or its parent is a link + if (popoverThrottle) return; + popoverThrottle = true; + setTimeout(function () { + handlePopoverEvents(event); + popoverThrottle = false; + }, 10); +}; + +/** + * Event handler for attaching preview popovers + * @param {Event} event The event produced by the mouseover or focus action + */ +function handlePopoverEvents (ev) { + let anchor = ev.target; + const iframeDoc = anchor.ownerDocument; + if (!iframeDoc) return; + const iframeWindow = iframeDoc.defaultView; + while (anchor && anchor !== iframeWindow && anchor.nodeName !== 'A') { + anchor = anchor.parentNode; + } + // If we're not hovering a link, then we can exit + if (!anchor || anchor.nodeName !== 'A') return; + // console.debug(event.type, event.target, a); + const suppressContextMenuHandler = function (e) { + e.preventDefault(); + e.stopPropagation(); + }; + // Prevent context menu on this anchor element + anchor.addEventListener('contextmenu', suppressContextMenuHandler, true); + if (/touchstart|pointerdown/.test(ev.type)) { + anchor.touched = true; // Used to prevent dismissal of popver on mouseout if initiated by touch + } + if (anchor.style.userSelect === undefined) { + // This prevents selection of the text in a touched link in Safari for iOS and Edge Legacy / UWP + anchor.style.webkitUserSelect = 'none'; + anchor.style.msUserSelect = 'none'; + } + // Check if a popover div is currently being hovered + const divArray = Array.from(iframeDoc.getElementsByClassName('kiwixtooltip')); + const divIsHovered = divArray.some(div => div.matches(':hover')); + // Only add a popover to the link if a current popover is not being hovered (prevents popovers showing for links in a popover) + if (!divIsHovered) { + // Prevent text selection while popover is open in modern browsers + anchor.style.userSelect = 'none'; + // Resolve the true app theme + const isDarkTheme = uiUtil.isDarkTheme(params.appTheme); + // Get and populate the popover corresponding to the hovered or focused link + popovers.populateKiwixPopoverDiv(ev, anchor, appstate, isDarkTheme, selectedArchive); + } + const outHandler = function (e) { + setTimeout(function () { + anchor.popoverisloading = false; + if (/blur/.test(e.type) || !anchor.touched) { + popovers.removeKiwixPopoverDivs(iframeDoc); + anchor.touched = false; + } + anchor.style.webkitUserSelect = 'auto'; + anchor.style.msUserSelect = 'auto'; + anchor.style.userSelect = 'auto'; + anchor.removeEventListener(e.type, outHandler); + anchor.removeEventListener('contextmenu', suppressContextMenuHandler, true); + }, 250); + }; + // Clean up when user stops hovering, lifts pointer, stops touching, or unfocuses (blurs) the link + if (/mouseover/.test(ev.type)) { + anchor.addEventListener('mouseleave', outHandler); + } + if (/pointerdown/.test(ev.type)) { + anchor.addEventListener('pointerup', outHandler); + } + if (/touchstart/.test(ev.type)) { + anchor.addEventListener('touchend', outHandler); + } + if (ev.type === 'focus') { + anchor.addEventListener('blur', outHandler); + } +} + // Handles a click on a Zimit link that has been processed by Wombat function handleClickOnReplayLink (ev, anchor) { var basePath = window.location.href.replace(/^(.*?\/)www\/.*$/, '$1'); @@ -2490,6 +2625,12 @@ function handleMessageChannelMessage (event) { window.timeout = setTimeout(function () { uiUtil.spinnerDisplay(false); }, 1000); + // Test for an HTML or XHTML article: note that some ZIMs have odd MIME type formatting like 'text/html;raw=true', + // or simply `html`, so this has to be as generic as possible + if (/\bx?html/i.test(mimetype)) { + // Calculate the current article's ZIM baseUrl to use when attaching popovers + appstate.baseUrl = encodeURI(dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '')); + } // Ensure the article onload event gets attached to the right iframe articleLoader(); } @@ -2559,7 +2700,7 @@ function displayArticleContentInIframe (dirEntry, htmlArticle) { // Calculate the current article's ZIM baseUrl to use when processing relative links // (duplicated because we sometimes bypass readArticle above) - var baseUrl = encodeURI(dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '')) + appstate.baseUrl = encodeURI(dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '')) // Add CSP to prevent external scripts and content - note that any existing CSP can only be hardened, not loosened htmlArticle = htmlArticle.replace(/(]*>)\s*/, '$1\n \n '); @@ -2623,7 +2764,7 @@ function displayArticleContentInIframe (dirEntry, htmlArticle) { // DEV: Note that deriveZimUrlFromRelativeUrl produces a *decoded* URL (and incidentally would remove any URI component // if we had captured it). We therefore re-encode the URI with encodeURI (which does not encode forward slashes) instead // of encodeURIComponent. - assetZIMUrlEnc = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(relAssetUrl, baseUrl)); + assetZIMUrlEnc = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(relAssetUrl, appstate.baseUrl)); } newBlock = blockStart + 'data-kiwixurl' + equals + assetZIMUrlEnc + blockClose; // Replace any srcset with data-kiwixsrcset @@ -2800,6 +2941,8 @@ function displayArticleContentInIframe (dirEntry, htmlArticle) { anchor.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); + // Prevent display of any popovers because we're loading a new article + anchor.articleisloading = true; anchorParameter = href.match(/#([^#;]+)$/); anchorParameter = anchorParameter ? anchorParameter[1] : ''; var indexRoot = window.location.pathname.replace(/[^/]+$/, '') + encodeURI(selectedArchive.file.name) + '/'; @@ -2820,30 +2963,33 @@ function displayArticleContentInIframe (dirEntry, htmlArticle) { // Zimit ZIMs store URLs percent-encoded and with querystring and // deriveZimUrlFromRelativeUrls strips any querystring and decodes var zimUrlToTransform = zimUrl; - zimUrl = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(zimUrlToTransform, baseUrl)) + + zimUrl = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(zimUrlToTransform, appstate.baseUrl)) + href.replace(uriComponent, '').replace('#' + anchorParameter, ''); - // zimUrlFullEncoding = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(zimUrlToTransform, baseUrl) + + // zimUrlFullEncoding = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(zimUrlToTransform, appstate.baseUrl) + // href.replace(uriComponent, '').replace('#' + anchorParameter, '')); } } else { // It's a relative URL, so we need to calculate the full ZIM URL - zimUrl = uiUtil.deriveZimUrlFromRelativeUrl(uriComponent, baseUrl); + zimUrl = uiUtil.deriveZimUrlFromRelativeUrl(uriComponent, appstate.baseUrl); } goToArticle(zimUrl, downloadAttrValue, contentType); + // DEV: There is no need to remove the anchor.articleisloading flag because we do not open new tabs for ZIM URLs in Safe Mode + // so the anchor will be erased form the DOM when the new article is loaded }); }); + attachPopoverTriggerEvents(iframeArticleContent.contentWindow); } function loadImagesJQuery () { // Make an array from the images that need to be processed var images = Array.prototype.slice.call(iframeArticleContent.contentDocument.querySelectorAll('img[data-kiwixurl]')); // This ensures cancellation of image extraction if the user navigates away from the page before extraction has finished - images.owner = expectedArticleURLToBeDisplayed; + images.owner = appstate.expectedArticleURLToBeDisplayed; // DEV: This self-invoking function is recursive, calling itself only when an image has been fully processed into a // blob: or data: URI (or returns an error). This ensures that images are processed sequentially from the top of the // DOM, making for a better user experience (because images above the fold are extracted first) (function extractImage () { - if (!images.length || images.busy || images.owner !== expectedArticleURLToBeDisplayed) return; + if (!images.length || images.busy || images.owner !== appstate.expectedArticleURLToBeDisplayed) return; images.busy = true; // Extract the image at the top of the images array and remove it from the array var image = images.shift(); @@ -2997,7 +3143,7 @@ function displayArticleContentInIframe (dirEntry, htmlArticle) { Array.prototype.slice.call(iframe.querySelectorAll('video, audio, source, track')) .forEach(function (mediaSource) { var source = mediaSource.getAttribute('src'); - source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, baseUrl) : null; + source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, appstate.baseUrl) : null; // We have to exempt text tracks from using deriveZimUrlFromRelativeurl due to a bug in Firefox [kiwix-js #496] source = source || decodeURIComponent(mediaSource.dataset.kiwixurl); if (!source || !regexpZIMUrlWithNamespace.test(source)) { diff --git a/www/js/init.js b/www/js/init.js index 092aa7409..694bdce79 100644 --- a/www/js/init.js +++ b/www/js/init.js @@ -56,7 +56,8 @@ * @property {string} cacheIDB - Name of the Indexed DB database * @property {boolean} isFileSystemApiSupported - A boolean indicating whether the FileSystem API is supported. * @property {boolean} isWebkitDirApiSupported - A boolean indicating whether the Webkit Directory API is supported. - * @property {boolean} useLibzim - A boolean indicating weather to use the libzim to load zim files. + * @property {boolean} useLibzim - A boolean indicating whether to use the libzim to load zim files. + * @property {boolean} showPopoverPreviews - A boolean indicating whether to show previews of ZIM links (currently only for Wikimedia archives) * @property {"wasm-dev" | 'wasm' | 'asm' | 'asm-dev' | 'default'} libzimMode - A value indicating which libzim mode is selected. * @property {DecompressorAPI} decompressorAPI @@ -132,6 +133,7 @@ params['libzimMode'] = getSetting('libzimMode') || 'wasm'; // Sets a value indic params['useLibzim'] = !!getSetting('useLibzim'); // Sets a value indicating which libzim mode is selected params['previousZimFileName'] = getSetting('previousZimFileName') || ''; // Sets the name of the last opened zim file params['reopenLastArchive'] = getSetting('reopenLastArchive') !== false; // Sets a Boolean defaulting to true indicating whether to reopen the last opened zim file if possible +params['showPopoverPreviews'] = getSetting('showPopoverPreviews') !== false; // Sets a Boolean defaulting to true indicating whether to show previews of article contents when hovering a ZIM link /** * Apply any override parameters that might be in the querystring. @@ -200,6 +202,7 @@ document.getElementById('useLibzim').checked = params.useLibzim; document.getElementById('appVersion').textContent = 'Kiwix ' + params.appVersion; document.getElementById('enableSourceVerification').checked = getSetting('sourceVerification') === null ? true : getSetting('sourceVerification'); document.getElementById('reopenLastArchiveCheck').checked = params.reopenLastArchive; +document.getElementById('showPopoverPreviewsCheck').checked = params.showPopoverPreviews; // If the File System Access API is supported, unhide the reopenLastArchiveDiv if (params.isFileSystemApiSupported) document.getElementById('reopenLastArchiveDiv').style.display = ''; diff --git a/www/js/lib/popovers.js b/www/js/lib/popovers.js new file mode 100644 index 000000000..a61f39615 --- /dev/null +++ b/www/js/lib/popovers.js @@ -0,0 +1,482 @@ +/** + * popovers.js : Functions to add popovers to the UI + * + * Copyright 2013-2024 Jaifroid 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'; + +/* global params */ + +import uiUtil from './uiUtil.js'; + +/** + * Parses a linked article in a loaded document in order to extract the first main paragraph (the 'lede') and first + * main image (if any). This function currently only parses Wikimedia articles. It returns an HTML string, formatted + * for display in a popover + * @param {String} href The href of the article link from which to extract the lede + * @param {String} baseUrl The base URL of the currently loaded article + * @param {Document} articleDocument The DOM of the currently loaded article + * @param {ZIMArchive} archive The archive from which to extract the lede + * @returns {Promise} A Promise for the linked article's lede HTML including first main image URL if any + */ +function getArticleLede (href, baseUrl, articleDocument, archive) { + const uriComponent = uiUtil.removeUrlParameters(href); + const zimURL = uiUtil.deriveZimUrlFromRelativeUrl(uriComponent, baseUrl); + console.debug('Previewing ' + zimURL); + const promiseForArticle = function (dirEntry) { + // Wrap legacy callback-based code in a Promise + return new Promise((resolve, reject) => { + // As we're reading Wikipedia articles, we can assume that they are UTF-8 encoded HTML data + archive.readUtf8File(dirEntry, function (fileDirEntry, htmlArticle) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlArticle, 'text/html'); + const articleBody = doc.body; + if (articleBody) { + // Establish the popup balloon's base URL and the absolute path for calculating the ZIM URL of links and images + const balloonBaseURL = encodeURI(fileDirEntry.namespace + '/' + fileDirEntry.url.replace(/[^/]+$/, '')); + const docUrl = new URL(articleDocument.location.href); + const rootRelativePathPrefix = docUrl.pathname.replace(/([^.]\.zim\w?\w?\/).+$/i, '$1'); + // Clean up the lede content + const nonEmptyParagraphs = cleanUpLedeContent(articleBody); + // Concatenate paragraphs to fill the balloon + let balloonString = ''; + if (nonEmptyParagraphs.length > 0) { + balloonString = fillBalloonString(nonEmptyParagraphs, balloonBaseURL, rootRelativePathPrefix); + } + // If we have a lede, we can now add an image to the balloon, but only if we are in ServiceWorker mode + if (balloonString && params.contentInjectionMode === 'serviceworker') { + const imageHTML = getImageHTMLFromNode(articleBody, balloonBaseURL, rootRelativePathPrefix); + if (imageHTML) { + balloonString = imageHTML + balloonString; + } + } + if (!balloonString) { + reject(new Error('No article lede or image')); + } else { + resolve(balloonString); + } + } else { + reject(new Error('No article body found')); + } + }); + }); + }; + const processDirEntry = function (dirEntry) { + if (!dirEntry) throw new Error('No directory entry found'); + if (dirEntry.redirect) { + return new Promise((resolve, reject) => { + archive.resolveRedirect(dirEntry, function (reDirEntry) { + if (!reDirEntry) reject(new Error('Could not resolve redirect')); + resolve(promiseForArticle(reDirEntry)); + }); + }); + } else { + return promiseForArticle(dirEntry); + } + }; + // Do a binary search in the URL index to get the directory entry for the requested article + return archive.getDirEntryByPath(zimURL).then(processDirEntry).catch(function (err) { + throw new Error('Could not get Directory Entry for ' + zimURL, err); + }); +}; + +// Helper function to clean up the lede content +function cleanUpLedeContent (node) { + // Remove all standalone style elements from the given DOM node, because their content is shown by innerText and textContent + const styleElements = Array.from(node.querySelectorAll('style')); + styleElements.forEach(style => { + style.parentNode.removeChild(style); + }); + const paragraphs = Array.from(node.querySelectorAll('p')); + // Filter out empty paragraphs or those with less than 50 characters + const parasWithContent = paragraphs.filter(para => { + const text = para.innerText.trim(); + return !/^\s*$/.test(text) && text.length >= 50; + }); + return parasWithContent; +} + +// Helper function to concatenate paragraphs to fill the balloon +function fillBalloonString (paras, baseURL, pathPrefix) { + let cumulativeCharCount = 0; + let concatenatedText = ''; + // Add enough paras to complete the word count + for (let i = 0; i < paras.length; i++) { + // Get the character count: to fill the larger box we need ~850 characters (815 plus leeway) + const plainText = paras[i].innerText; + cumulativeCharCount += plainText.length; + // In ServiceWorker mode, we need to transform the URLs of any links in the paragraph + if (params.contentInjectionMode === 'serviceworker') { + const links = Array.from(paras[i].querySelectorAll('a')); + links.forEach(link => { + const href = link.getAttribute('href'); + if (href && !/^#/.test(href)) { + const zimURL = uiUtil.deriveZimUrlFromRelativeUrl(href, baseURL); + link.href = pathPrefix + encodeURI(zimURL); + } + }); + } + // Get the transformed HTML. Note that in Safe mode, we risk breaking the UI if user clicks on an + // embedded link, so only use innerText in that case + const content = params.contentInjectionMode === 'jquery' ? plainText + : paras[i].innerHTML; + concatenatedText += '

' + content + '

'; + // If we have enough characters to fill the box, break + if (cumulativeCharCount >= 850) break; + } + return concatenatedText; +} + +// Helper function to get the first main image from the given node +function getImageHTMLFromNode (node, baseURL, pathPrefix) { + const images = node.querySelectorAll('img'); + let firstImage = null; + if (images) { + // Iterate over images until we find one with a width greater than 50 pixels + // (this filters out small icons) + const imageArray = Array.from(images); + for (let j = 0; j < imageArray.length; j++) { + if (imageArray[j] && imageArray[j].width > 50) { + firstImage = imageArray[j]; + break; + } + } + } + if (firstImage) { + // Calculate root relative URL of image + const imageZimURL = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(firstImage.getAttribute('src'), baseURL)); + firstImage.src = pathPrefix + imageZimURL; + return firstImage.outerHTML; + } +} + +/** + * A function to attach the tooltip CSS for popovers (NB this does not attach the box itself, only the CSS) + * @param {Document} doc The document to which to attach the popover stylesheet + * @param {Boolean} dark An optional parameter to adjust the background colour for dark themes (generally not needed for inversion-based themes) + */ +function attachKiwixPopoverCss (doc, dark) { + const colour = dark && !/invert/i.test(params.cssTheme) ? 'darkgray' : 'black'; + const backgroundColour = dark && !/invert/i.test(params.cssTheme) ? '#111' : '#ebf4fb'; + const borderColour = dark ? 'darkslategray' : 'skyblue'; + const cssLink = document.createElement('link'); + doc.head.appendChild(cssLink); + // DEV: Firefox OS blocks loading stylesheet files into iframe DOM content even if it is same origin, so we are forced to insert a style element instead + uiUtil.replaceCSSLinkWithInlineCSS(cssLink, ` + .kiwixtooltip { + position: absolute; + bottom: 1em; + /* prettify */ + padding: 0 5px 5px; + color: ${colour}; + background: ${backgroundColour}; + border: 0.1em solid ${borderColour}; + /* round the corners */ + border-radius: 0.5em; + /* handle overflow */ + overflow: visible; + text-overflow: ellipsis; + /* handle text wrap */ + overflow-wrap: break-word; + word-wrap: break-word; + /* add fade-in transition */ + opacity: 0; + transition: opacity 0.3s; + } + + .kiwixtooltip img { + float: right; + margin-left: 5px; + max-width: 40%; + height: auto; + } + + #popcloseicon { + padding-top: 1px; + padding-right: 2px; + font-size: 20px; + font-family: sans-serif; + } + + #popcloseicon:hover { + cursor: pointer; + } + + #popbreakouticon { + height: 18px; + margin-right: 18px; + } + + #popbreakouticon:hover { + cursor: pointer; + } + + /* Prevent native iOS popover on Safari if option is enabled */ + body { + -webkit-touch-callout: none !important; + } + `, + // The id of the style element for easy manipulation + 'kiwixtooltipstylesheet'); +} + +/** + * Attaches a popover div for the given link to the given document's DOM + * @param {Event} ev The event which has fired this popover action + * @param {Element} link The link element that is being actioned + * @param {Object} state The globlal object defined in app.js that holds the current state of the app + * @param {Boolean} dark An optional value to switch colour theme to dark if true + * @param {ZIMArchive} archive The archive from which the popover information is extracted + * @returns {Promise
} A Promise for the attached popover div or undefined if the popover is not attached + */ +function populateKiwixPopoverDiv (ev, link, state, dark, archive) { + // Do not show popover if the user has initiated an article load (set in filterClickEvent) + if (link.articleisloading || link.popoverisloading) return Promise.resolve(); + const linkHref = link.getAttribute('href'); + // Do not show popover if there is no href or with certain landing pages + if (!linkHref || /^wikivoyage/i.test(archive.file.name) && + (state.expectedArticleURLToBeDisplayed === archive.landingPageUrl || + state.expectedArticleURLToBeDisplayed === 'A/Wikivoyage:Offline_reader_Expedition/Home_page')) { + return Promise.resolve(); + } + link.popoverisloading = true; + // Do not display a popover if one is already showing for the current link + const kiwixPopover = ev.target.ownerDocument.querySelector('.kiwixtooltip'); + // DEV: popoverIsLoading will get reset in app.js after user deselects link + if (kiwixPopover && kiwixPopover.dataset.href === linkHref) return Promise.resolve(); + // console.debug('Attaching popover...'); + const currentDocument = ev.target.ownerDocument; + const articleWindow = currentDocument.defaultView; + // Remove any existing popover(s) that the user may not have closed before creating a new one + removeKiwixPopoverDivs(currentDocument); + // Timeout below ensures that popovers are not loaded if a user is simply moving their mouse around on a page + // without hovering. It provides a 600ms pause before app begins the process of binary search and decompression + setTimeout(function () { + // Check if the user has moved away from the link or has clicked it, and abort display of popover if so + if (link.articleisloading || !link.matches(':hover') && !link.touched && currentDocument.activeElement !== link) { + // Aborting popover display because user has moved away from link or clicked it + link.popoverisloading = false; + return; + } + // Create a new Kiwix popover container + const divWithArrow = createNewKiwixPopoverCointainer(articleWindow, link, ev); + const div = divWithArrow.div; + const span = divWithArrow.span; + // Get the article's 'lede' (first main paragraph or two) and the first main image (if any) + getArticleLede(linkHref, state.baseUrl, currentDocument, archive).then(function (html) { + div.style.justifyContent = ''; + div.style.alignItems = ''; + div.style.display = 'block'; + const breakoutIconFile = window.location.pathname.replace(/\/[^/]*$/, '') + (dark ? '/img/icons/new_window_white.svg' : '/img/icons/new_window_black.svg'); + const backgroundColour = dark && !/invert/i.test(params.appTheme) ? 'black' : '#ebf4fb'; + // DEV: Most style declarations in this div only work properly inline. If added in stylesheet, even with !important, the positioning goes awry + // (appears to be a timing issue related to the reservation of space given that the div is inserted dynamically). + div.innerHTML = `
+
+ + X +
+
${html}
+
`; + // Now it is populated, we can attach the arrow to the div + div.appendChild(span); + // Programme the icons + addEventListenersToPopoverIcons(link, div, currentDocument); + setTimeout(function () { + div.popoverisloading = false; + }, 900); + }).catch(function (err) { + console.warn(err); + // Remove the div + div.style.opacity = '0'; + div.parentElement.removeChild(div); + link.dataset.touchevoked = false; + link.popoverisloading = false; + }); + }, 600); +} + +/** + * Create a new empty Kiwix popover container and attach it to the current document appropriately sized and positioned + * in relation to the given anchor and available screen width and height. Also returns the arrow span element which can be + * attached to the div after the div is populated with content. + * @param {Window} win The window of the article DOM + * @param {Element} anchor The anchor element that is being actioned + * @param {Event} event The event which has fired this popover action + * @returns {Object} An object containing the popover div and the arrow span elements + */ +function createNewKiwixPopoverCointainer (win, anchor, event) { + const div = document.createElement('div'); + const linkHref = anchor.getAttribute('href'); + const currentDocument = win.document; + div.popoverisloading = true; + const screenWidth = win.innerWidth - 40; + const screenHeight = document.documentElement.clientHeight; + let margin = 40; + let divWidth = 512; + if (screenWidth <= divWidth) { + divWidth = screenWidth; + margin = 10; + } + // Check if we have restricted screen height + const divHeight = screenHeight < 512 ? 160 : 256; + div.style.width = divWidth + 'px'; + div.style.height = divHeight + 'px'; + div.style.display = 'flex'; + div.style.justifyContent = 'center'; + div.style.alignItems = 'center'; + div.className = 'kiwixtooltip'; + div.innerHTML = '

Loading ...

'; + div.dataset.href = linkHref; + // DEV: We need to insert the div into the target document before we can obtain its computed dimensions accurately + currentDocument.body.appendChild(div); + // Calculate the position of the link that is being hovered + const linkRect = anchor.getBoundingClientRect(); + // Initially position the div 20px above the link + let triangleDirection = 'top'; + const divOffsetHeight = div.offsetHeight + 20; + let divRectY = linkRect.top - divOffsetHeight; + let triangleY = divHeight + 6; + // If we're less than half margin from the top, move the div below the link + if (divRectY < margin / 2) { + triangleDirection = 'bottom'; + divRectY = linkRect.bottom + 20; + triangleY = -16; + } + // Position it horizontally in relation to the pointer position + let divRectX, triangleX; + if (event.type === 'touchstart') { + divRectX = event.touches[0].clientX - divWidth / 2; + triangleX = event.touches[0].clientX - divRectX - 20; + } else if (event.type === 'focus') { + divRectX = linkRect.left + linkRect.width / 2 - divWidth / 2; + triangleX = linkRect.left + linkRect.width / 2 - divRectX - 20; + } else { + divRectX = event.clientX - divWidth / 2; + triangleX = event.clientX - divRectX - 20; + } + // If right edge of div is greater than margin from the right side of window, shift it to margin + if (divRectX + divWidth > screenWidth - margin) { + triangleX += divRectX; + divRectX = screenWidth - divWidth - margin; + triangleX -= divRectX; + } + // If we're less than margin to the left, shift it to margin px from left + if (divRectX < margin) { + triangleX += divRectX; + divRectX = margin; + triangleX -= divRectX; + } + // Adjust triangleX if necessary + if (triangleX < 10) triangleX = 10; + if (triangleX > divWidth - 10) triangleX = divWidth - 10; + // Now set the calculated x and y positions + div.style.top = divRectY + win.scrollY + 'px'; + div.style.left = divRectX + 'px'; + div.style.opacity = '1'; + // Now create the arrow span element. Note that we cannot attach it yet as we need to populate the div first + // and doing so will overwrite the innerHTML of the div + const triangleColour = getComputedStyle(div).borderColor; // Same as border colour of div + const span = document.createElement('span'); + span.style.cssText = ` + width: 0; + height: 0; + border-${triangleDirection}: 16px solid ${triangleColour} !important; + border-left: 8px solid transparent !important; + border-right: 8px solid transparent !important; + position: absolute; + top: ${triangleY}px; + left: ${triangleX}px; + `; + return { div: div, span: span }; +} + +/** + * Adds event listeners to the popover's control icons + * @param {Element} anchor The anchor which launched the popover + * @param {Element} popover The containing element of the popover (div) + * @param {Document} doc The doucment on which to operate + */ +function addEventListenersToPopoverIcons (anchor, popover, doc) { + const breakout = function (e) { + // Adding the newcontainer property to the anchor will be cauught by the filterClickEvent function and will open in new tab + anchor.newcontainer = true; + anchor.click(); + closePopover(popover); + } + const closeIcon = doc.getElementById('popcloseicon'); + const breakoutIcon = doc.getElementById('popbreakouticon'); + // Register mousedown event (should work in all contexts) + closeIcon.addEventListener('mousedown', function () { + closePopover(popover); + }, true); + breakoutIcon.addEventListener('mousedown', breakout, true); +} + +/** + * Remove any preview popover DIVs found in the given document + * @param {Document} doc The document from which to remove any popovers + */ +function removeKiwixPopoverDivs (doc) { + const divs = doc.getElementsByClassName('kiwixtooltip'); + // Timeout is set to allow for a delay before removing popovers - so user can hover the popover itself to prevent it from closing, + // or so that links and buttons in the popover can be clicked + setTimeout(function () { + // Gather any popover divs (on rare occasions, more than one may be displayed) + Array.prototype.slice.call(divs).forEach(function (div) { + // Do not remove any popover in process of loading + if (div.popoverisloading) return; + let timeoutID; + const fadeOutDiv = function () { + clearTimeout(timeoutID); + // Do not close any div which is being hovered + if (!div.matches(':hover')) { + closePopover(div); + } else { + timeoutID = setTimeout(fadeOutDiv, 250); + } + }; + timeoutID = setTimeout(fadeOutDiv, 0); + }); + }, 400); +} + +/** + * Closes the specified popover div, with fadeout effect, and removes it from the DOM + * @param {Element} div The div to close + */ +function closePopover (div) { + div.style.opacity = '0'; + // Timeout allows the animation to complete before removing the div + setTimeout(function () { + if (div && div.parentElement) { + div.parentElement.removeChild(div); + } + }, 200); +}; + +/** + * Functions and classes exposed by this module + */ +export default { + attachKiwixPopoverCss: attachKiwixPopoverCss, + populateKiwixPopoverDiv: populateKiwixPopoverDiv, + removeKiwixPopoverDivs: removeKiwixPopoverDivs +}; diff --git a/www/js/lib/uiUtil.js b/www/js/lib/uiUtil.js index 20a2b8a82..1018c04de 100644 --- a/www/js/lib/uiUtil.js +++ b/www/js/lib/uiUtil.js @@ -402,10 +402,14 @@ function determineCanvasElementsWorkaround () { * * @param {Element} link The original link node from the DOM * @param {String} cssContent The content to insert as an inline stylesheet + * @param {String} id An optional id to add to the style element */ -function replaceCSSLinkWithInlineCSS (link, cssContent) { +function replaceCSSLinkWithInlineCSS (link, cssContent, id) { var cssElement = document.createElement('style'); - cssElement.type = 'text/css'; + if (id) { + cssElement.id = id; + } + cssElement.type = 'text/css'; // Still needed for FFOS if (cssElement.styleSheet) { cssElement.styleSheet.cssText = cssContent; } else { @@ -848,9 +852,8 @@ function tabTransitionToSection (toSection, isAnimationRequired = false) { * @param {String} theme The theme to apply (light|dark[_invert|_mwInvert]|auto[_invert|_mwInvert]) */ 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(/_.*$/, ''); + var appTheme = isDarkTheme(theme) ? 'dark' : 'light'; // Get contentTheme from chosen theme var contentTheme = theme.replace(/^[^_]*/, ''); var htmlEl = document.querySelector('html'); @@ -922,6 +925,11 @@ function applyAppTheme (theme) { } } +// 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'); @@ -1075,6 +1083,7 @@ export default { removeAnimationClasses: removeAnimationClasses, tabTransitionToSection: tabTransitionToSection, applyAppTheme: applyAppTheme, + isDarkTheme: isDarkTheme, reportAssemblerErrorToAPIStatusPanel: reportAssemblerErrorToAPIStatusPanel, reportSearchProviderToAPIStatusPanel: reportSearchProviderToAPIStatusPanel, warnAndOpenExternalLinkInNewTab: warnAndOpenExternalLinkInNewTab,