diff --git a/service-worker.js b/service-worker.js index 8dc86b26..68a6102a 100644 --- a/service-worker.js +++ b/service-worker.js @@ -196,6 +196,7 @@ const precacheFiles = [ 'www/js/lib/filecache.js', 'www/js/lib/images.js', 'www/js/lib/kiwixServe.js', + 'www/js/lib/popovers.js', 'www/js/lib/settingsStore.js', 'www/js/lib/transformStyles.js', 'www/js/lib/transformZimit.js', @@ -260,6 +261,8 @@ if (typeof chrome !== 'undefined' && chrome.action) { // Process install event self.addEventListener('install', function (event) { console.debug('[SW] Install Event processing'); + // DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate... + // self.skipWaiting(); // We try to circumvent the browser's cache by adding a header to the Request, and it ensures all files are explicitly versioned var requests = precacheFiles.map(function (urlPath) { return new Request(urlPath + '?v' + appVersion, { cache: 'no-cache' }); @@ -277,11 +280,6 @@ self.addEventListener('install', function (event) { }); }) ); - // .then(function () { - // // DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate... - // // console.warn('Attempting to skip waiting...'); - // // self.skipWaiting(); - // }); })); } }); @@ -419,12 +417,6 @@ self.addEventListener('fetch', function (event) { } const range = modRequestOrResponse.headers.get('range'); return fetchUrlFromZIM(urlObject, range).then(function (response) { - // // DEV: For normal reads, this is now done in app.js, but for libzim, we have to do it here - // // Add css or js assets to ASSETS_CACHE (or update their cache entries) unless the URL schema is not supported - // if (data && data.origin === 'libzim' && regexpCachedContentTypes.test(response.headers.get('Content-Type')) && - // !regexpExcludedURLSchema.test(event.request.url)) { - // event.waitUntil(updateCache(ASSETS_CACHE, rqUrl, response.clone())); - // } return cacheAndReturnResponseForAsset(event, response); }).catch(function (msgPortData) { console.error('Invalid message received from app.js for ' + strippedUrl, msgPortData); @@ -633,7 +625,7 @@ function zimitResolver (event, rqUrl) { } else { // The loaded ZIM archive is not a Zimit archive, or sw-Zimit is unsupported, so we should just return the request // If the reqUrl is not the same as event.request.url, we need to modify the request - // Note that we can't clone requests with streaming bodies, hence we check for an error and use another method in that cse (see https://developer.mozilla.org/en-US/docs/Web/API/Request/clone) + // Note that we can't clone requests with streaming bodies, hence we check for an error and use another method in that case (see https://developer.mozilla.org/en-US/docs/Web/API/Request/clone) var rtnRequest; try { rtnRequest = new Request(rqUrl, event.request); @@ -767,11 +759,9 @@ function fetchUrlFromZIM (urlObjectOrString, range, expectedHeaders) { var httpResponse = new Response(slicedData, responseInit); // Let's send the content back from the ServiceWorker - // resolve({ response: httpResponse, data: msgPortEvent.data }); resolve(httpResponse); } else if (msgPortEvent.data.action === 'sendRedirect') { console.debug('[SW] Redirecting to ' + msgPortEvent.data.redirectUrl); - // resolve({ response: Response.redirect(prefix + msgPortEvent.data.redirectUrl) }); resolve(Response.redirect(prefix + msgPortEvent.data.redirectUrl)); } else { reject(msgPortEvent.data, titleWithNameSpace); diff --git a/www/-/s/style-dark.css b/www/-/s/style-dark.css index e98bb18a..6a3f621d 100644 --- a/www/-/s/style-dark.css +++ b/www/-/s/style-dark.css @@ -299,7 +299,7 @@ div[style*="background-color: #cee0f2"], div[style*="background-color: #cedff2"] border-color: lightgray !important; } - div:not([style*="-color"]), div[style*="background-color"], span:not([class*="color"]), + div:not([style*="-color"]):not(.kiwixtooltip), div[style*="background-color"]:not(.kiwixtooltip), span:not([class*="color"]), summary:not([style*="-color"]), table, tr:not([style*="border-bottom"]), th, td:not([style*="border-bottom"]), h1, h2, h3, h4, h5, h6, ul, li, input, select, #bodyContent code { border-color: #555 !important; diff --git a/www/js/app.js b/www/js/app.js index 081b855d..e3303b05 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -29,6 +29,7 @@ // 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'; @@ -5231,11 +5232,13 @@ function filterClickEvent (event) { return; } // Remove any Kiwix Popovers that may be hanging around - uiUtil.removeKiwixPopoverDivs(event.target.ownerDocument); + popovers.removeKiwixPopoverDivs(event.target.ownerDocument); if (params.contentInjectionMode === 'jquery') return; // Trap clicks in the iframe to restore Fullscreen mode if (params.lockDisplayOrientation) refreshFullScreen(event); 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 @@ -5329,7 +5332,7 @@ var articleLoadedSW = function (dirEntry, container) { if (!appstate.isReplayWorkerAvailable) { // We need to keep tabs on the opened tabs or windows if the user wants right-click functionality, and also parse download links // We need to set a timeout so that dynamically generated URLs are parsed as well (e.g. in Gutenberg ZIMs) - if (params.windowOpener && !appstate.pureMode && !params.useLibzim && dirEntry) { + if ((params.windowOpener || appstate.wikimediaZimLoaded) && !appstate.pureMode && !params.useLibzim && dirEntry) { setTimeout(function () { parseAnchorsJQuery(dirEntry); }, 1500); @@ -5373,7 +5376,8 @@ var articleLoadedSW = function (dirEntry, container) { } if (dirEntry) uiUtil.makeReturnLink(dirEntry.getTitleOrUrl()); if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { - uiUtil.attachKiwixPopoverCss(doc, params.cssTheme === 'darkReader'); + var darkTheme = (params.cssUITheme == 'auto' ? cssUIThemeGetOrSet('auto', true) : params.cssUITheme) !== 'light'; + popovers.attachKiwixPopoverCss(doc, darkTheme); } params.isLandingPage = false; } else { @@ -6540,7 +6544,8 @@ function displayArticleContentInContainer (dirEntry, htmlArticle) { loadCSSJQuery(); images.prepareImagesJQuery(articleWindow); if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { - uiUtil.attachKiwixPopoverCss(articleWindow.document); + var darkTheme = (params.cssUITheme == 'auto' ? cssUIThemeGetOrSet('auto', true) : params.cssUITheme) !== 'light'; + popovers.attachKiwixPopoverCss(articleWindow.document, darkTheme); } var determinedTheme = params.cssTheme === 'auto' ? cssUIThemeGetOrSet('auto') : params.cssTheme; if (params.allowHTMLExtraction && appstate.target === 'iframe') { @@ -6797,6 +6802,7 @@ function loadCSSJQuery () { * @param {String} baseUrl The baseUrl against which relative links will be calculated */ function addListenersToLink (a, href, baseUrl) { + appstate.baseUrl = baseUrl; var uriComponent = uiUtil.removeUrlParameters(href); // var namespace = baseUrl.replace(/^([-ABCIJMUVWX])\/.+/, '$1'); var loadingContainer = false; @@ -6828,7 +6834,7 @@ function addListenersToLink (a, href, baseUrl) { a.newcontainer = false; } loadingContainer = false; - a.articleloading = false; + a.articleisloading = false; a.dataset.touchevoked = false; a.popoverisloading = false; }; @@ -6923,7 +6929,7 @@ function addListenersToLink (a, href, baseUrl) { if (!a.touched || a.newcontainer || appstate.startVector) return; if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { a.dataset.touchevoked = true; - uiUtil.attachKiwixPopoverDiv(event, a, baseUrl, darkTheme); + popovers.populateKiwixPopoverDiv(event, a, appstate, darkTheme, appstate.selectedArchive); } else { a.newcontainer = true; onDetectedClick(event); @@ -6937,7 +6943,7 @@ function addListenersToLink (a, href, baseUrl) { a.newcontainer = false; loadingContainer = false; // Cancel any popovers because user has clicked - a.articleloading = true; + a.articleisloading = true; setTimeout(reset, 1000); }); // This detects right-click in all browsers (only if the option is enabled) @@ -6952,7 +6958,7 @@ function addListenersToLink (a, href, baseUrl) { // return; } else if (!a.touched) { a.touched = true; - uiUtil.attachKiwixPopoverDiv(e, a, baseUrl, darkTheme); + popovers.populateKiwixPopoverDiv(e, a, appstate, darkTheme, appstate.selectedArchive); } } else { if (!params.windowOpener) return; @@ -7001,14 +7007,20 @@ function addListenersToLink (a, href, baseUrl) { // 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) if (appstate.wikimediaZimLoaded && params.showPopoverPreviews && 'matches' in Element.prototype) { + // Prevent accidental selection of the anchor text in some contexts + if (a.style.userSelect === undefined && appstate.wikimediaZimLoaded && params.showPopoverPreviews) { + // This prevents selection of the text in a touched link in iOS Safari + a.style.webkitUserSelect = 'none'; + a.style.msUserSelect = 'none'; + } a.addEventListener('mouseover', function (e) { // console.debug('a.mouseover'); if (a.dataset.touchevoked === 'true') return; - uiUtil.attachKiwixPopoverDiv(e, a, baseUrl, darkTheme); + popovers.populateKiwixPopoverDiv(e, a, appstate, darkTheme, appstate.selectedArchive); }); a.addEventListener('mouseout', function (e) { if (a.dataset.touchevoked === 'true') return; - uiUtil.removeKiwixPopoverDivs(e.target.ownerDocument); + popovers.removeKiwixPopoverDivs(e.target.ownerDocument); setTimeout(reset, 1000); }); a.addEventListener('focus', function (e) { @@ -7016,7 +7028,7 @@ function addListenersToLink (a, href, baseUrl) { // console.debug('a.focus'); if (a.touched) return; a.focused = true; - uiUtil.attachKiwixPopoverDiv(e, a, baseUrl, darkTheme); + popovers.populateKiwixPopoverDiv(e, a, appstate, darkTheme, appstate.selectedArchive); }, 200); }); a.addEventListener('blur', function (e) { @@ -7029,7 +7041,7 @@ function addListenersToLink (a, href, baseUrl) { a.addEventListener('click', function (e) { console.log('a.click', e); // Cancel any popovers because user has clicked - a.articleloading = true; + a.articleisloading = true; // Prevent opening multiple windows if (loadingContainer || a.touched) { e.preventDefault(); diff --git a/www/js/lib/popovers.js b/www/js/lib/popovers.js new file mode 100644 index 00000000..f599c4b8 --- /dev/null +++ b/www/js/lib/popovers.js @@ -0,0 +1,486 @@ +/** + * 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'; + // 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.insertLinkElement(doc, ` + .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.cssTheme) ? '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 = /UWP/.test(params.appType) ? div.offsetHeight * params.relativeFontSize / 100 + 20 : div.offsetHeight + 20; + let divRectY = linkRect.top - divOffsetHeight; + if (/UWP/.test(params.appType)) divRectY = divRectY * 100 / params.relativeFontSize; + 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; + if (/UWP/.test(params.appType)) divRectY = divRectY * 100 / params.relativeFontSize; + } + // 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 * params.relativeFontSize / 100 > screenWidth - margin) { + triangleX += divRectX; + divRectX = screenWidth - divWidth * params.relativeFontSize / 100 - margin; + triangleX -= divRectX; + } + // If we're less than margin to the left, shift it to margin px from left + if (divRectX * params.relativeFontSize / 100 < margin) { + triangleX += divRectX; + divRectX = margin; + triangleX -= divRectX; + } + // Adjust triangleX if necessary + if (triangleX < 10) triangleX = 10; + if (triangleX > divWidth - 10) triangleX = divWidth - 10; + // Adjust positions to take into account the font zoom factor + divRectX = divRectX * 100 / params.relativeFontSize; + triangleX = triangleX * 100 / params.relativeFontSize; + const adjustedScrollY = win.scrollY * 100 / params.relativeFontSize; + // Now set the calculated x and y positions + div.style.top = divRectY + adjustedScrollY + '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).borderBottomColor; // Same as border colour of div (UWP needs specific border colour) + 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 a838072a..37e0cca9 100644 --- a/www/js/lib/uiUtil.js +++ b/www/js/lib/uiUtil.js @@ -1420,382 +1420,6 @@ function lockDisplayOrientation (val) { } } -/** - * 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 - * @returns {Promise} A Promise for the linked article's lede HTML including first main image URL if any - */ -function getArticleLede (href, baseUrl, articleDocument) { - var uriComponent = removeUrlParameters(href); - var zimURL = deriveZimUrlFromRelativeUrl(uriComponent, baseUrl); - console.debug('Previewing ' + zimURL); - return appstate.selectedArchive.getDirEntryByPath(zimURL).then(function (dirEntry) { - var readArticle = function (dirEntry) { - return new Promise((resolve, reject) => { - appstate.selectedArchive.readUtf8File(dirEntry, function (fileDirEntry, htmlArticle) { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlArticle, 'text/html'); - // const articleBody = doc.getElementById('mw-content-text'); - const articleBody = doc.body; - if (articleBody) { - let balloonString = ''; - // Remove all standalone style elements, because their content is shown by both innerText and textContent - const styleElements = Array.from(articleBody.querySelectorAll('style')); - styleElements.forEach(style => { - style.parentNode.removeChild(style); - }); - const paragraphs = Array.from(articleBody.querySelectorAll('p')); - // Filter out empty paragraphs or those with less than 50 characters - const nonEmptyParagraphs = paragraphs.filter(para => { - const text = para.innerText.trim(); - return !/^\s*$/.test(text) && text.length >= 50; - }); - if (nonEmptyParagraphs.length > 0) { - var cumulativeCharCount = 0; - // Add enough paras to complete the word count - for (let i = 0; i < nonEmptyParagraphs.length; i++) { - // Get the character count: to fill the larger box we need ~850 characters (815 plus leeway) - var plainText = nonEmptyParagraphs[i].innerText; - cumulativeCharCount += plainText.length; - // In Restricted mode, we risk breaking the UI if user clicks on an embedded link, so only use innerText - var content = params.contentInjectionMode === 'jquery' ? plainText - : nonEmptyParagraphs[i].innerHTML; - balloonString += '

' + content + '

'; - // console.debug('Cumulatve character count: ' + cumulativeCharCount); - if (cumulativeCharCount >= 850) break; - } - } - const images = articleBody.querySelectorAll('img'); - let firstImage = null; - if (images && params.contentInjectionMode === 'serviceworker') { - // 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 absolute URL of image - var balloonBaseURL = encodeURI(fileDirEntry.namespace + '/' + fileDirEntry.url.replace(/[^/]+$/, '')); - var imageZimURL = encodeURI(deriveZimUrlFromRelativeUrl(firstImage.getAttribute('src'), balloonBaseURL)); - var absolutePath = articleDocument.location.href.replace(/([^.]\.zim\w?\w?\/).+$/i, '$1'); - firstImage.src = absolutePath + imageZimURL; - balloonString = firstImage.outerHTML + balloonString; - } - // console.debug(balloonString); - if (!balloonString) { - reject(new Error('No article lede or image')); - } else { - resolve(balloonString); - } - } else { - reject(new Error('No article body found')); - } - }); - }); - } - if (dirEntry.redirect) { - return new Promise((resolve, reject) => { - appstate.selectedArchive.resolveRedirect(dirEntry, function (reDirEntry) { - resolve(readArticle(reDirEntry)); - }); - }).catch(error => { - return Promise.reject(error); - }); - } else { - return Promise.resolve(readArticle(dirEntry)); - } - }); -} - -/** - * 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 blloon.css styelesheet - * @param {Boolean} dark An optional parameter to adjust the background colour for dark themes - */ -function attachKiwixPopoverCss (doc, dark) { - const colour = dark ? '#darkgray' : '#black'; - const backgroundColour = dark ? '#111' : '#ebf4fb'; - insertLinkElement(doc, ` - .kiwixtooltip { - position: absolute; - bottom: 1em; - /* prettify */ - padding: 0 5px 5px; - color: ${colour}; - background: ${backgroundColour}; - border: 0.1em solid #b7ddf2; - /* 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; - }`, - // 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 {String} articleBaseUrl The base URL of the currently loaded document - * @param {Boolean} dark An optional value to switch colour theme to dark if true - */ -function attachKiwixPopoverDiv (ev, link, articleBaseUrl, dark) { - // Do not show popover if the user has initiated an article load - if (link.articleloading || link.popoverisloading) return; - var linkHref = link.getAttribute('href'); - // Do not show popover if there is no href or with certain landing pages - if (!linkHref || /^wikivoyage/i.test(appstate.selectedArchive.file.name) && - (appstate.expectedArticleURLToBeDisplayed === appstate.selectedArchive.landingPageUrl || - appstate.expectedArticleURLToBeDisplayed === 'A/Wikivoyage:Offline_reader_Expedition/Home_page')) { - return; - } - link.popoverisloading = true; - // Do not disply a popover if one is already showing for the current link - var kiwixPopover = ev.target.ownerDocument.querySelector('.kiwixtooltip'); - if (kiwixPopover && kiwixPopover.dataset.href === linkHref) return; - // console.debug('Attaching popover...'); - var currentDocument = ev.target.ownerDocument; - var articleWindow = currentDocument.defaultView; - removeKiwixPopoverDivs(currentDocument); - setTimeout(function () { - // Do not show popover if the user has initiated an article load - if (link.articleloading) return; - // Check if the link is still being hovered over, and abort display of popover if not - if (!link.matches(':hover') && currentDocument.activeElement !== link) { - link.popoverisloading = false; - return; - } - var div = document.createElement('div'); - div.popoverisloading = true; // console.debug('div.popoverisloading', div.popoverisloading); - var screenWidth = articleWindow.innerWidth - 40; - var screenHeight = document.documentElement.clientHeight; - var margin = 40; - var divWidth = 512; - if (screenWidth <= divWidth) { - divWidth = screenWidth; - margin = 10; - } - // Check if we have restricted screen height - var 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; - currentDocument.body.appendChild(div); - // Calculate the position of the link that is being hovered - var linkRect = link.getBoundingClientRect(); - // Initially position the div 20px above the link - var triangleDirection = 'top'; - var divOffsetHeight = /UWP/.test(params.appType) ? div.offsetHeight * params.relativeFontSize / 100 + 20 : div.offsetHeight + 20; - var divRectY = linkRect.top - divOffsetHeight; - if (/UWP/.test(params.appType)) divRectY = divRectY * 100 / params.relativeFontSize; - var 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; - if (/UWP/.test(params.appType)) divRectY = divRectY * 100 / params.relativeFontSize; - } - // Position it horizontally in relation to the pointer position - var divRectX, triangleX; - if (ev.type === 'touchstart') { - divRectX = ev.touches[0].clientX - divWidth / 2; - triangleX = ev.touches[0].clientX - divRectX - 20; - } else if (ev.type === 'focus') { - divRectX = linkRect.left + linkRect.width / 2 - divWidth / 2; - triangleX = linkRect.left + linkRect.width / 2 - divRectX - 20; - } else { - divRectX = ev.clientX - divWidth / 2; - triangleX = ev.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 * params.relativeFontSize / 100 > screenWidth - margin) { - triangleX += divRectX; - divRectX = screenWidth - divWidth * params.relativeFontSize / 100 - margin; - triangleX -= divRectX; - } - // If we're less than margin to the left, shift it to margin px from left - if (divRectX * params.relativeFontSize / 100 < margin) { - triangleX += divRectX; - divRectX = margin; - triangleX -= divRectX; - } - // Adjust triangleX if necessary - if (triangleX < 10) triangleX = 10; - if (triangleX > divWidth - 10) triangleX = divWidth - 10; - // Adjust positions to take into account the font zoom factor - divRectX = divRectX * 100 / params.relativeFontSize; - triangleX = triangleX * 100 / params.relativeFontSize; - var adjustedScrollY = articleWindow.scrollY * 100 / params.relativeFontSize; - // Now set the calculated x and y positions, taking into account the zoom factor - div.style.top = divRectY + adjustedScrollY + 'px'; - div.style.left = divRectX + 'px'; - div.style.opacity = '1'; - getArticleLede(linkHref, articleBaseUrl, currentDocument).then(function (html) { - link.articleloading = false; - div.style.justifyContent = ''; - div.style.alignItems = ''; - div.style.display = 'block'; - var breakoutIconFile = window.location.pathname.replace(/\/[^/]*$/, '') + (dark ? '/img/icons/new_window_white.svg' : '/img/icons/new_window_black.svg'); - var backgroundColour = dark ? '#222' : '#ebf4fb'; - div.innerHTML = `
-
- - X -
-
${html}
-
`; - // Now insert the arrow - var tooltipStyle = articleWindow.document.getElementById('kiwixtooltipstylesheet'); - var triangleColour = '#b7ddf2'; // Same as border colour of div - if (tooltipStyle) { - var span = document.createElement('span'); - span.style.cssText = ` - width: 0; - height: 0; - border-${triangleDirection}: 16px solid ${triangleColour}; - border-left: 8px solid transparent !important; - border-right: 8px solid transparent !important; - position: absolute; - top: ${triangleY}px; - left: ${triangleX}px; - `; - div.appendChild(span); - } - // Programme the icons - addEventListenersToPopoverIcons(link, div, currentDocument); - setTimeout(function () { - div.popoverisloading = false; // console.debug('div.popoverisloading', div.popoverisloading); - }, 900); - }).catch(function (err) { - console.warn(err); - // Remove the div - div.style.opacity = '0'; - div.parentElement.removeChild(div); - link.articleloading = false; - link.dataset.touchevoked = false; - link.popoverisloading = false; - }); - }, 600); -} - -/** - * 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) { - var breakout = function (e) { - e.preventDefault(); - e.stopPropagation(); - anchor.newcontainer = true; - anchor.click(); - closePopover(popover); - } - var closeIcon = doc.getElementById('popcloseicon'); - var breakoutIcon = doc.getElementById('popbreakouticon'); - // Register click event for full support - closeIcon.addEventListener('mousedown', function () { - closePopover(popover); - }, true); - breakoutIcon.addEventListener('mousedown', breakout, true); - // Register either pointerdown or touchstart if supported - var eventName = window.PointerEvent ? 'pointerdown' : 'touchstart'; - closeIcon.addEventListener(eventName, function (e) { - e.preventDefault(); - e.stopPropagation(); - closePopover(popover); - }, true); - breakoutIcon.addEventListener(eventName, breakout, true); -} - -/** - * Remove any preview popover DIVs - * - * @param {Document} doc The document from which to remove any popovers - */ -function removeKiwixPopoverDivs (doc) { - var divs = doc.getElementsByClassName('kiwixtooltip'); - setTimeout(function () { - Array.prototype.slice.call(divs).forEach(function (div) { - if (div.popoverisloading) return; - var timeoutID; - var fadeOutDiv = function () { - clearTimeout(timeoutID); - if (!div.matches(':hover')) { - closePopover(div); - } else { - timeoutID = setTimeout(fadeOutDiv, 250); - } - }; - timeoutID = setTimeout(fadeOutDiv, 0); - }); - }, 400); -} - -// Directly close any popovers -function closePopover (div) { - div.style.opacity = '0'; - setTimeout(function () { - if (div && div.parentElement) { - div.parentElement.removeChild(div); - } - }, 200); -}; - /** * Finds the closest or enclosing tag of an element. * Returns undefined if there isn't any. @@ -1857,9 +1481,6 @@ export default { initTouchZoom: initTouchZoom, appIsFullScreen: appIsFullScreen, lockDisplayOrientation: lockDisplayOrientation, - attachKiwixPopoverCss: attachKiwixPopoverCss, - attachKiwixPopoverDiv: attachKiwixPopoverDiv, - removeKiwixPopoverDivs: removeKiwixPopoverDivs, reportAssemblerErrorToAPIStatusPanel: reportAssemblerErrorToAPIStatusPanel, reportSearchProviderToAPIStatusPanel: reportSearchProviderToAPIStatusPanel, warnAndOpenExternalLinkInNewTab: warnAndOpenExternalLinkInNewTab,