MediaWiki:Gadget-TimelinePreview.js

From Angelina Jordan Wiki
Revision as of 17:26, 11 November 2025 by Most2dot0 (talk | contribs) (new approach: replace regular's page preview content instead of supressing them)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// Gadget: Timeline hover previews (mixed suppression + popup-replace)
// Requires: mediawiki.api, jquery, mediawiki.util
mw.loader.using(['mediawiki.api', 'jquery', 'mediawiki.util']).then(function () {
    var api = new mw.Api();
    var timelineCache = null;

    // Custom popup for links we fully suppress
    var customPopup = $('<div>')
        .attr('id', 'timeline-preview-popup')
        .css({
            position: 'absolute',
            zIndex: 1100,
            background: '#fff',
            border: '1px solid #a2a9b1',
            borderRadius: '2px',
            padding: '0.75em 1em',
            boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
            maxWidth: '360px',
            display: 'none'
        })
        .appendTo(document.body);

    function loadTimeline() {
        if (timelineCache) return $.Deferred().resolve();
        return api.get({
            action: 'parse',
            page: 'Timeline',
            prop: 'text',
            formatversion: 2
        }).then(function (data) {
            var html = $('<div>').html(data.parse.text);
            timelineCache = {};
            html.find('dt').each(function () {
                var dt = $(this);
                var id = dt.find('span[id]').attr('id');
                if (!id) return;
                var ddList = [];
                var el = dt.next();
                while (el.length && el.prop('tagName') === 'DD') {
                    var clone = el.clone();
                    // strip citation superscripts
                    clone.find('sup.reference').remove();
                    // replace links with their text
                    clone.find('a').replaceWith(function () {
                        return $(this).text();
                    });
                    ddList.push(clone.html());
                    el = el.next();
                }
                timelineCache[id] = ddList.join('<br>');
            });
        });
    }

    function showCustomPopup($link, html) {
        customPopup.html(html).show();
        var offset = $link.offset();
        customPopup.css({
            top: offset.top + $link.outerHeight() + 6,
            left: offset.left
        });
    }
    function hideCustomPopup() { customPopup.hide(); }

    // detect if element is effectively visible (not display:none and attached)
    function isVisibleElement(el) {
        return el && el.nodeType === 1 && el.offsetParent !== null;
    }

    // selector to find timeline links in a context
    var timelineLinkSelector = 'a[href*="Timeline#"], a[href^="#"]';

    // Replace the content inside a system popup element with our html
    function replaceSystemPopupContent(popupNode, html) {
        try {
            var $p = $(popupNode);
            // prefer known extract container classes if present
            var extract = $p.find('.mwe-popups-extract, .mwe-popups-container, .popups-extract').first();
            if (extract && extract.length) {
                extract.html(html);
            } else {
                // fallback: replace popup inner content
                $p.html(html);
            }
        } catch (e) {
            // silent fail
        }
    }

    // Observe newly added nodes briefly and replace the popup content when found.
    // Returns a disconnectable observer handle.
    function watchForSystemPopupOnce($link, html, timeoutMs) {
        timeoutMs = timeoutMs || 1500;
        var observer = new MutationObserver(function (mutations, obs) {
            for (var i = 0; i < mutations.length; i++) {
                var m = mutations[i];
                for (var j = 0; j < m.addedNodes.length; j++) {
                    var node = m.addedNodes[j];
                    if (node.nodeType !== 1) continue;
                    var cls = node.className || '';
                    // Heuristics: common PagePreview popup classes include "mwe-popups" or "popups" or "popups-container"
                    if (/\bmwe-popups\b/.test(cls) || /\bpopups\b/.test(cls) || /\bpopups-container\b/.test(cls)) {
                        replaceSystemPopupContent(node, html);
                        obs.disconnect();
                        return;
                    }
                    // also consider descendants
                    var $node = $(node);
                    var found = $node.find('.mwe-popups, .popups, .popups-container, .mwe-popups-extract, .popups-extract').first();
                    if (found && found.length) {
                        replaceSystemPopupContent(found.get(0), html);
                        obs.disconnect();
                        return;
                    }
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
        // safety timeout
        var to = setTimeout(function () { observer.disconnect(); }, timeoutMs);
        return {
            disconnect: function () { clearTimeout(to); observer.disconnect(); }
        };
    }

    function onLinkMouseEnter(e) {
        var $link = $(this);
        var href = $link.attr('href') || '';
        var match = href.match(/\/Timeline#(\d{4}-\d{2}-\d{2})$/) || href.match(/^#(\d{4}-\d{2}-\d{2})$/);
        if (!match) return;
        var id = match[1];

        // If we bound this link as disabled earlier, show our custom popup
        if ($link.data('timeline-disabled')) {
            loadTimeline().then(function () {
                var content = timelineCache && timelineCache[id];
                if (content) showCustomPopup($link, content);
            });
            return;
        }

        // Otherwise this link was not disabled (likely initially hidden). Let system create popup,
        // but watch for it and replace its content when it appears.
        loadTimeline().then(function () {
            var content = timelineCache && timelineCache[id];
            if (!content) return;

            // Start observing for a system popup
            var handle = watchForSystemPopupOnce($link, content, 2000);

            // Also hide our custom popup if it was visible by some chance
            hideCustomPopup();

            // When mouse leaves before popup appears, disconnect observer
            $link.one('mouseleave.timelinePopupReplace', function () {
                try { handle.disconnect(); } catch (err) {}
            });
        });
    }

    function onLinkMouseLeave(e) {
        // hide custom popup if used
        hideCustomPopup();
        // mouseleave also remove one-time listener used for system popup replacement
        $(this).off('mouseleave.timelinePopupReplace');
    }

    // Disable previews for links that are currently visible. For links that are hidden at bind time,
    // leave them alone so their system popup can be used and later replaced on hover.
    function bindTimelineLinks(context) {
        $(context).find(timelineLinkSelector).each(function () {
            var $link = $(this);
            var href = $link.attr('href') || '';
            if (!(href.match(/Timeline#\d{4}-\d{2}-\d{2}$/) || href.match(/^#\d{4}-\d{2}-\d{2}$/))) return;

            // avoid double-binding
            if ($link.data('timeline-bound')) return;
            $link.data('timeline-bound', true);

            // if link is visible now, disable built-in previews and use custom popup
            if (isVisibleElement(this)) {
                $link.attr('data-mw-previews-disable', 'true');
                // Attempt to call ext.popups.disablePreview hook if available (best-effort)
                if (mw.hook && mw.hook('ext.popups.disablePreview')) {
                    try { mw.hook('ext.popups.disablePreview').fire($link.get(0)); } catch (e) {}
                }
                $link.data('timeline-disabled', true);
                $link.off('.timelinePreview')
                    .on('mouseenter.timelinePreview', onLinkMouseEnter)
                    .on('mouseleave.timelinePreview', onLinkMouseLeave);
            } else {
                // link not visible at bind time (collapsed). Do not disable preview.
                $link.data('timeline-disabled', false);
                $link.off('.timelinePreview')
                    .on('mouseenter.timelinePreview', onLinkMouseEnter)
                    .on('mouseleave.timelinePreview', onLinkMouseLeave);
            }
        });
    }

    // initial binding on content load
    mw.hook('wikipage.content').add(function (context) { bindTimelineLinks(context); });

    // Observe added nodes and bind links inside them (but respect visibility at bind time)
    var observer = new MutationObserver(function (mutations) {
        for (var i = 0; i < mutations.length; i++) {
            var m = mutations[i];
            for (var j = 0; j < m.addedNodes.length; j++) {
                var node = m.addedNodes[j];
                if (node.nodeType === 1) bindTimelineLinks(node);
            }
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });
});