From 7a072ad6dd0c0c87a306a5b383564984a5d752a3 Mon Sep 17 00:00:00 2001 From: Jaifroid Date: Sun, 12 May 2024 12:03:22 +0100 Subject: [PATCH] Add Wikipedia preview function #278 (#595) --- KiwixWebApp-github.jsproj | 2 + KiwixWebApp.jsproj | 2 + README.md | 4 +- service-worker.js | 4 + www/-/s/style-dark.css | 2 +- www/img/icons/new_window_black.svg | 8 + www/img/icons/new_window_white.svg | 8 + www/index.html | 7 +- www/js/app.js | 146 ++++++++---- www/js/init.js | 2 + www/js/lib/uiUtil.js | 361 +++++++++++++++++++++++++++++ 11 files changed, 500 insertions(+), 46 deletions(-) create mode 100644 www/img/icons/new_window_black.svg create mode 100644 www/img/icons/new_window_white.svg diff --git a/KiwixWebApp-github.jsproj b/KiwixWebApp-github.jsproj index 776c4bf7b..3656510bc 100644 --- a/KiwixWebApp-github.jsproj +++ b/KiwixWebApp-github.jsproj @@ -245,7 +245,9 @@ + + diff --git a/KiwixWebApp.jsproj b/KiwixWebApp.jsproj index 7c02c071d..5e44f91e9 100644 --- a/KiwixWebApp.jsproj +++ b/KiwixWebApp.jsproj @@ -246,7 +246,9 @@ + + diff --git a/README.md b/README.md index 2abb64f9c..806254997 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ try switching the app to Restricted mode (see Content injection mode in Configur + Google Chrome / Chromium >= 59 (and many browsers based on Chromium, e.g. Opera, Samsung Internet) + Microsoft Edge (Chromium) >= 79 - + Mozilla Firefox >= 60 (but see note about Android`*`) + + Mozilla Firefox >= 68 (but see note about Android`*`) + Apple Safari >= 11.3 for iOS and macOS (full-text search only works on iOS 15+) + Microsoft Edge Legacy 18 (Windows only) @@ -101,7 +101,7 @@ Although deprecated, we will keep support for as long as is practical: * Internet Explorer 11 (Restricted mode only, no offline use of PWA) * Edge Legacy <= 17 (Restricted mode only, no offline use of PWA) -* Firefox 45-59 (some versions require the user to switch manually to Restricted mode) +* Firefox 45-67 (some versions require the user to switch manually to Restricted mode, and some are unable to display WebP images) * Chromium 49-58 (some versions only run in Restricted mode) ## Reporting bugs and technical support diff --git a/service-worker.js b/service-worker.js index 40be31207..08a8d65a1 100644 --- a/service-worker.js +++ b/service-worker.js @@ -182,6 +182,10 @@ const precacheFiles = [ 'www/img/icons/wikivoyage-white-32.png', 'www/img/icons/map_marker-30px.png', 'www/img/icons/map_marker-18px.png', + 'www/img/icons/new_window.svg', + 'www/img/icons/new_window_black.svg', + 'www/img/icons/new_window_lb.svg', + 'www/img/icons/new_window_white.svg', 'www/img/spinner.gif', 'www/index.html', 'www/article.html', diff --git a/www/-/s/style-dark.css b/www/-/s/style-dark.css index 501eacc93..e98bb18a3 100644 --- a/www/-/s/style-dark.css +++ b/www/-/s/style-dark.css @@ -170,7 +170,7 @@ tr[style*="background: antiquewhite"], tr[style*="background-color:#ee"], tr[sty .mw-ui-button[style*="background"], .mw-ui-button[style*="background"] *, .wikiEditor-ui, table.navbox.collapsible tr:nth-child(2) > td, div.menu, div.NavHead, .oo-ui-popupWidget-popup, .oo-ui-buttonElement-button, .mw-notification, .mwe-popups, .mwe-popups-is-not-tall, .mwe-popups-is-tall, -.ui-widget-content, .oo-ui-window-body, #pagehistory li.selected, .tracklist tr, .dataTable tr { +.ui-widget-content, .oo-ui-window-body, #pagehistory li.selected, .tracklist tr, .dataTable tr, div.kiwixtooltip { background-color: #222 !important; } 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 2713feba8..96a8062b1 100644 --- a/www/index.html +++ b/www/index.html @@ -1137,7 +1137,7 @@

Privacy settings

Performance / compatibility

-
Speed up archive access
+
Caching and preview settings
@@ -1169,6 +1169,11 @@

Performance / compatibility

+
diff --git a/www/js/app.js b/www/js/app.js index 228fabae8..7853cce4d 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -2096,6 +2096,11 @@ function setWindowOpenerUI () { newWin.style.display = 'none'; } } +document.getElementById('showPopoverPreviewsCheck').addEventListener('change', function (e) { + params.showPopoverPreviews = e.target.checked; + settingsStore.setItem('showPopoverPreviews', params.showPopoverPreviews, Infinity); + params.themeChanged = true; +}); document.getElementById('allowHTMLExtractionCheck').addEventListener('click', function (e) { params.allowHTMLExtraction = e.target.checked; var alertMessage = ''; @@ -5205,11 +5210,8 @@ function articleLoader (entry, mimeType) { // Add event listener to iframe window to check for links to external resources function filterClickEvent (event) { // console.debug('filterClickEvent fired'); - if (params.contentInjectionMode === 'jquery') return; // Ignore click if we are dealing with an image that has not yet been extracted if (event.target.dataset && event.target.dataset.kiwixhidden) return; - // Trap clicks in the iframe to restore Fullscreen mode - if (params.lockDisplayOrientation) refreshFullScreen(event); // Find the closest enclosing A tag (if any) var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target); // If the anchor has a passthrough property, then we have already checked it is safe, so we can return @@ -5217,6 +5219,11 @@ function filterClickEvent (event) { clickedAnchor.passthrough = false; return; } + // Remove any Kiwix Popovers that may be hanging around + uiUtil.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) { // 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 @@ -5354,6 +5361,9 @@ var articleLoadedSW = function (dirEntry, container) { anchorParameter = ''; } if (dirEntry) uiUtil.makeReturnLink(dirEntry.getTitleOrUrl()); + if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { + uiUtil.attachKiwixPopoverCss(doc, params.cssTheme === 'darkReader'); + } params.isLandingPage = false; } else { // If we havent' loaded a text-type document, we probably haven't finished loading @@ -6518,7 +6528,9 @@ function displayArticleContentInContainer (dirEntry, htmlArticle) { parseAnchorsJQuery(dirEntry); loadCSSJQuery(); images.prepareImagesJQuery(articleWindow); - // loadJavascript(); //Disabled for now, since it does nothing - also, would have to load before images, ideally through controlled css loads above + if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { + uiUtil.attachKiwixPopoverCss(articleWindow.document); + } var determinedTheme = params.cssTheme === 'auto' ? cssUIThemeGetOrSet('auto') : params.cssTheme; if (params.allowHTMLExtraction && appstate.target === 'iframe') { uiUtil.insertBreakoutLink(determinedTheme); @@ -6547,6 +6559,8 @@ function displayArticleContentInContainer (dirEntry, htmlArticle) { } anchorParameter = ''; } + // Trap clicks in the iframe (currently only used for removing popovers in Restricted mode) + articleWindow.onclick = filterClickEvent; params.isLandingPage = false; }; @@ -6711,15 +6725,14 @@ function loadCSSJQuery () { // These sections can be opened by clicking on them, but this is done with some javascript. // The code below is a workaround we still need for compatibility with ZIM files generated by mwoffliner in 2018. // A better fix has been made for more recent ZIM files, with the use of noscript tags : see https://github.com/openzim/mwoffliner/issues/324 - var iframe = articleContainer.contentDocument; - var collapsedBlocks = iframe.querySelectorAll('.collapsible-block:not(.open-block), .collapsible-heading:not(.open-block)'); + var collapsedBlocks = articleDocument.querySelectorAll('.collapsible-block:not(.open-block), .collapsible-heading:not(.open-block)'); // Using decrementing loop to optimize performance : see https://stackoverflow.com/questions/3520688 for (var i = collapsedBlocks.length; i--;) { collapsedBlocks[i].classList.add('open-block'); } var cssCount = 0; var cssFulfilled = 0; - Array.prototype.slice.call(iframe.querySelectorAll('link[data-kiwixurl]')).forEach(function (link) { + Array.prototype.slice.call(articleDocument.querySelectorAll('link[data-kiwixurl]')).forEach(function (link) { cssCount++; var linkUrl = link.getAttribute('data-kiwixurl'); var url = decodeURIComponent(/zimit/.test(appstate.selectedArchive.zimType) ? linkUrl : uiUtil.removeUrlParameters(linkUrl)); @@ -6804,6 +6817,7 @@ function addListenersToLink (a, href, baseUrl) { a.newcontainer = false; } loadingContainer = false; + a.articleloading = false; }; var onDetectedClick = function (e) { // Restore original values for this window/tab @@ -6875,49 +6889,79 @@ function addListenersToLink (a, href, baseUrl) { setTimeout(reset, 1400); }; + var darkTheme = (params.cssUITheme == 'auto' ? cssUIThemeGetOrSet('auto', true) : params.cssUITheme) !== 'light'; + + /* Event processing */ a.addEventListener('touchstart', function (e) { - if (!params.windowOpener || a.touched) return; - e.stopPropagation(); - // e.preventDefault(); + // console.debug('a.touchstart'); + var timeout = 500; + if (!appstate.wikimediaZimLoaded || !params.showPopoverPreviews) { + if (!params.windowOpener || a.touched) return; + loadingContainer = true; + } else { + timeout = 200; + } a.touched = true; - loadingContainer = true; var event = e; - // The link will be clicked if the user long-presses for more than 800ms (if the option is enabled) + // The link will be clicked if the user long-presses for more than 500ms (if the option is enabled), or 200ms for popover setTimeout(function () { // DEV: appstate.startVector indicates that the app is processing a touch zoom event, so we cancel any new windows // see uiUtil.pointermove_handler if (!a.touched || a.newcontainer || appstate.startVector) return; - e.preventDefault(); - a.newcontainer = true; - onDetectedClick(event); - }, 800); + if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { + a.dataset.touchevoked = true; + uiUtil.attachKiwixPopoverDiv(event, a, baseUrl, darkTheme); + } else { + a.newcontainer = true; + onDetectedClick(event); + } + event.preventDefault(); + }, timeout); }, { passive: false }); a.addEventListener('touchend', function () { + // console.debug('a.touchend'); a.touched = false; a.newcontainer = false; loadingContainer = false; + // Cancel any popovers because user has clicked + a.articleloading = true; + setTimeout(reset, 1000); }); // This detects right-click in all browsers (only if the option is enabled) a.addEventListener('contextmenu', function (e) { - console.debug('contextmenu'); - if (!params.windowOpener) return; - if (params.rightClickType === 'double' && !a.touched) { - a.touched = true; - setTimeout(function () { - a.touched = false; - }, 700); - } else { - if (a.newcontainer) return; // Prevent accidental double activation + // console.debug('contextmenu'); + if (appstate.wikimediaZimLoaded && params.showPopoverPreviews) { e.preventDefault(); e.stopPropagation(); - a.newcontainer = true; - a.touched = false; - onDetectedClick(e); + // console.debug('suppressed contextmenu because processing popovers'); + var kiwixPopover = e.target.ownerDocument.querySelector('.kiwixtooltip'); + if (kiwixPopover) { + // return; + } else if (!a.touched) { + a.touched = true; + uiUtil.attachKiwixPopoverDiv(e, a, baseUrl, darkTheme); + } + } else { + if (!params.windowOpener) return; + if (params.rightClickType === 'double' && !a.touched) { + a.touched = true; + setTimeout(function () { + a.touched = false; + }, 700); + } else { + if (a.newcontainer) return; // Prevent accidental double activation + e.preventDefault(); + e.stopPropagation(); + a.newcontainer = true; + a.touched = false; + onDetectedClick(e); + } } }); // This traps the middle-click event before tha auxclick event fires a.addEventListener('mousedown', function (e) { - console.debug('mosuedown'); + // console.debug('a.mousedown'); + a.dataset.touchevoked = true; // This is needed to simulate touch events in UWP app if (!params.windowOpener) return; e.preventDefault(); e.stopPropagation(); @@ -6926,22 +6970,49 @@ function addListenersToLink (a, href, baseUrl) { a.newcontainer = true; onDetectedClick(e); } else { - console.debug('suppressed mousedown'); + // console.debug('suppressed mousedown'); } }); // This detects the middle-click event that opens a new tab in recent Firefox and Chrome // See https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event a.addEventListener('auxclick', function (e) { - console.debug('auxclick'); + // console.debug('a.auxclick'); if (!params.windowOpener) return; e.preventDefault(); e.stopPropagation(); }); + // 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) { + a.addEventListener('mouseover', function (e) { + // console.debug('a.mouseover'); + if (a.dataset.touchevoked) return; + uiUtil.attachKiwixPopoverDiv(e, a, baseUrl, darkTheme); + }); + a.addEventListener('mouseout', function (e) { + if (a.dataset.touchevoked) return; + uiUtil.removeKiwixPopoverDivs(e.target.ownerDocument); + }); + a.addEventListener('focus', function (e) { + setTimeout(function () { // Delay focus event so touchstart can fire first + // console.debug('a.focus'); + if (a.touched) return; + a.focused = true; + uiUtil.attachKiwixPopoverDiv(e, a, baseUrl, darkTheme); + }, 200); + }); + a.addEventListener('blur', function (e) { + // console.debug('a.blur'); + a.focused = false; + }); + } // The main click routine (called by other events above as well) a.addEventListener('click', function (e) { - console.log('Click event', e); + console.log('a.click', e); + // Cancel any popovers because user has clicked + a.articleloading = true; // Prevent opening multiple windows - if (loadingContainer || a.touched || a.newcontainer) { + if (loadingContainer || a.touched) { e.preventDefault(); e.stopPropagation(); } else { @@ -6969,15 +7040,6 @@ function displayHiddenBlockElements (win, doc) { 'app to decide when to apply the setting. If you never want to see hidden elements, even in Wikimedia ZIMs, change the ' + 'setting to never.

'; } - // else if (params.displayHiddenBlockElements === 'auto') { - // message = '

There is a new auto setting in Configuration to display hidden elements (navigation boxes, series tables) ' + - // "in Wikimedia ZIMs. This is now on by default. If you don't want to see these elements, change the 'Display hidden block elements' " + - // "setting to never (under 'Display style').

"; - // if (params.cssSource !== 'desktop') { - // message += '

Please note that hidden elements are always displayed in Desktop style (regardless of the setting)' + - // (params.cssSource !== 'desktop' ? '. You can switch the display style to Desktop in Configuration' : '') + '.

'; - // } - // } if (message) { message += '

This message will not be displayed again, unless you reset the app.

'; params.noHiddenElementsWarning = true; diff --git a/www/js/init.js b/www/js/init.js index bec69a39e..177f985b4 100644 --- a/www/js/init.js +++ b/www/js/init.js @@ -91,6 +91,7 @@ params['manipulateImages'] = getSetting('manipulateImages') != null ? getSetting params['linkToWikimediaImageFile'] = getSetting('linkToWikimediaImageFile') == true; // Links images to Wikimedia online version if ZIM archive is a Wikipedia archive params['hideToolbars'] = getSetting('hideToolbars') != null ? getSetting('hideToolbars') : true; // Set default to true (hides both), 'top' (hides top only), or false (no hiding) params['rememberLastPage'] = getSetting('rememberLastPage') != null ? getSetting('rememberLastPage') : true; // Set default option to remember the last visited page between sessions +params['showPopoverPreviews'] = getSetting('showPopoverPreviews') !== false; // Allows popover previews of articles for Wikimedia ZIMs (defaults to true) params['assetsCache'] = getSetting('appCache') !== false; // Whether to use cache by default or not params['appCache'] = getSetting('appCache') !== false; // Will be true by default unless explicitly set to false params['useMathJax'] = getSetting('useMathJax') != null ? getSetting('useMathJax') : true; // Set default to true to display math formulae with MathJax, false to use fallback SVG images only @@ -263,6 +264,7 @@ document.getElementById('hideToolbarsCheck').indeterminate = params.hideToolbars document.getElementById('hideToolbarsCheck').readOnly = params.hideToolbars === 'top'; document.getElementById('hideToolbarsState').innerHTML = (params.hideToolbars === 'top' ? 'top' : params.hideToolbars ? 'both' : 'never'); document.getElementById('openExternalLinksInNewTabsCheck').checked = params.openExternalLinksInNewTabs; +document.getElementById('showPopoverPreviewsCheck').checked = params.showPopoverPreviews; document.getElementById('disableDragAndDropCheck').checked = params.disableDragAndDrop; document.getElementById('debugLibzimASMDrop').value = params.debugLibzimASM || ''; if (params.debugLibzimASM === 'disable') document.getElementById('debugLibzimASMDrop').style.color = 'red'; diff --git a/www/js/lib/uiUtil.js b/www/js/lib/uiUtil.js index 01612a60f..f30f5c781 100644 --- a/www/js/lib/uiUtil.js +++ b/www/js/lib/uiUtil.js @@ -538,6 +538,25 @@ function deriveZimUrlFromRelativeUrl (url, base) { return decodeURIComponent(zimUrl.pathname.replace(/^\//, '')); } +/** + * Inserts a new link element into the document header + * @param {Element} doc The document to which to attach the new element + * @param {String} cssContent The content to insert as an inline stylesheet + * @param {String} id An optional id to add to the style element + */ +function insertLinkElement (doc, cssContent, id) { + var cssElement = document.createElement('style'); + if (id) { + cssElement.id = id; + } + if (cssElement.styleSheet) { + cssElement.styleSheet.cssText = cssContent; + } else { + cssElement.appendChild(document.createTextNode(cssContent)); + } + doc.head.appendChild(cssElement); +} + /** * Walk up the DOM tree to find the closest element where the tagname matches the supplied regular expression * @@ -1396,6 +1415,344 @@ 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) { + // Add two paras (becuase one sometimes isn't enough to fill the box) + for (let i = 0; i < 2; i++) { + // In Restricted mode, we risk breaking the UI if user clicks on an embedded link, so only use innerText + var content = params.contentInjectionMode === 'jquery' ? nonEmptyParagraphs[i].innerText + : nonEmptyParagraphs[i].innerHTML; + balloonString += '

' + content + '

'; + } + } + 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) { + // console.debug('Cancelled display of popover because user is loading the underlying article'); + return; + } + // Do not disply a popover if one is already showing for the current link + var kiwixPopover = ev.target.ownerDocument.querySelector('.kiwixtooltip'); + var linkHref = link.getAttribute('href'); + if (kiwixPopover && kiwixPopover.dataset.href === linkHref) return; + // console.debug('Attaching popover...'); + var currentDocument = ev.target.ownerDocument; + var articleWindow = currentDocument.defaultView; + removeKiwixPopoverDivs(currentDocument); + setTimeout(function () { + // Check if the link is still being hovered over, and abort display of popover if not + if (!linkHref || !link.matches(':hover') && currentDocument.activeElement !== link) return; + var div = document.createElement('div'); + 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(); + // Here's how to position it 40px above the pointer position (DEV: this doesn't work well due to lag) + // var divRectY = e.clientY - div.offsetHeight - 20; + // Initially position the div 20px above the link + var triangleDirection = 'top'; + var divRectY = (linkRect.top - div.offsetHeight - 20); + 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; + } + // 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 > 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 + articleWindow.scrollY + '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 + var breakout = function (e) { + e.preventDefault(); + e.stopPropagation(); + link.newcontainer = true; + link.click(); + closePopover(div); + } + var closeIcon = currentDocument.getElementById('popcloseicon'); + var breakoutIcon = currentDocument.getElementById('popbreakouticon'); + // Register click event for full support + closeIcon.addEventListener('mousedown', function () { + closePopover(div); + }, 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(div); + }, true); + breakoutIcon.addEventListener(eventName, breakout, true); + }).catch(function (err) { + console.warn(err); + // Remove the div + div.style.opacity = '0'; + div.parentElement.removeChild(div); + link.articleloading = false; + }); + }, 500); +} + +/** + * 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) { + var timeoutID; + var fadeOutDiv = function () { + clearTimeout(timeoutID); + if (!div.matches(':hover')) { + closePopover(div); + } else { + timeoutID = setTimeout(fadeOutDiv, 250); + } + }; + timeoutID = setTimeout(fadeOutDiv, 250); + }); + }, 300); +} + +// 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. @@ -1436,6 +1793,7 @@ export default { feedNodeWithBlob: feedNodeWithBlob, getDataUriFromUint8Array: getDataUriFromUint8Array, deriveZimUrlFromRelativeUrl: deriveZimUrlFromRelativeUrl, + insertLinkElement: insertLinkElement, getClosestMatchForTagname: getClosestMatchForTagname, removeUrlParameters: removeUrlParameters, ToC: TableOfContents, @@ -1456,6 +1814,9 @@ export default { initTouchZoom: initTouchZoom, appIsFullScreen: appIsFullScreen, lockDisplayOrientation: lockDisplayOrientation, + attachKiwixPopoverCss: attachKiwixPopoverCss, + attachKiwixPopoverDiv: attachKiwixPopoverDiv, + removeKiwixPopoverDivs: removeKiwixPopoverDivs, reportAssemblerErrorToAPIStatusPanel: reportAssemblerErrorToAPIStatusPanel, reportSearchProviderToAPIStatusPanel: reportSearchProviderToAPIStatusPanel, warnAndOpenExternalLinkInNewTab: warnAndOpenExternalLinkInNewTab,