Port code updates for popovers from Kiwix JS (#607)

This commit is contained in:
Jaifroid 2024-05-27 11:21:47 +01:00 committed by GitHub
parent 9b224bc64c
commit 5ec729de5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 515 additions and 406 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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();

486
www/js/lib/popovers.js Normal file
View File

@ -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 <http://www.gnu.org/licenses/>
*/
'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<String>} 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 += '<p>' + content + '</p>';
// 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<div>} 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 = `<div style="position: relative; overflow: hidden; height: ${div.style.height};">
<div style="background: ${backgroundColour} !important; opacity: 70%; position: absolute; top: 0; right: 0; display: flex; align-items: center; padding: 0; z-index: 1;">
<img id="popbreakouticon" src="${breakoutIconFile}" />
<span id="popcloseicon">X</span>
</div>
<div style="padding-top: 3px">${html}</div>
</div>`;
// 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 = '<p>Loading ...</p>';
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
};

View File

@ -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<String>} 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 += '<p>' + content + '</p>';
// 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 = '<p>Loading ...</p>';
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 = `<div style="position: relative; overflow: hidden; height: ${divHeight}px;">
<div style="background: ${backgroundColour} !important; opacity: 70%; position: absolute; top: 0; right: 0; display: flex; align-items: center; padding: 0;">
<img id="popbreakouticon" src="${breakoutIconFile}" />
<span id="popcloseicon">X</span>
</div>
<div style="padding-top: 3px">${html}</div>
</div>`;
// 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 <a> or <area> 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,