✨🎊 Postal Wiki 三周年! 🎊✨
🎂 2026.09.11 — 邮路三载,百科同行

用户:Hehua/filepreview.js

来自Postal Wiki

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5Ctrl-R(Mac为⌘-R
  • Google Chrome:Ctrl-Shift-R(Mac为⌘-Shift-R
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5
/**
 * FilePreview — 文件链接悬停预览脚本
 * 鼠标悬停在 File: 蓝色链接上时,弹出浮窗显示:
 *   - 缩略图预览
 *   - 文件信息(尺寸、大小、上传者)
 *   - 分类 (categories)
 *   - 版权/许可模板 (templates)
 *
 * 安装方式:将本脚本的内容复制到你的
 *   User:<你的名字>/common.js 或 MediaWiki:Common.js
 * 或通过 importScript 引入。
 */
mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
    'use strict';

    var CONFIG = {
        delay: 400,         // 悬停延迟 (ms)
        thumbWidth: 220,    // 缩略图宽度
        maxPopupWidth: 460,
        maxPopupHeight: 520
    };

    /* ---------- state ---------- */
    var state = {
        link: null,
        fetchTimer: null,
        hideTimer: null,
        popup: null
    };

    /* ---------- popup DOM ---------- */
    function getPopup() {
        if (!state.popup) {
            var el = document.createElement('div');
            el.id = 'filepreview-popup';
            el.style.cssText = [
                'display:none',
                'position:fixed',
                'z-index:9999',
                'background:#fff',
                'border:1px solid #c8ccd1',
                'border-radius:6px',
                'box-shadow:0 4px 16px rgba(0,0,0,0.2)',
                'padding:14px',
                'max-width:' + CONFIG.maxPopupWidth + 'px',
                'max-height:' + CONFIG.maxPopupHeight + 'px',
                'overflow-y:auto',
                'font:13px/1.5 sans-serif',
                'color:#222',
                'pointer-events:auto'
            ].join(';');
            document.body.appendChild(el);
            state.popup = el;

            // 鼠标移到浮窗上时不隐藏
            el.addEventListener('mouseenter', function () {
                clearTimeout(state.hideTimer);
                clearTimeout(state.fetchTimer);
            });
            el.addEventListener('mouseleave', function (e) {
                if (e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('a') === state.link) {
                    return;
                }
                hidePopup();
            });
        }
        return state.popup;
    }

    function showPopup(x, y, html) {
        var popup = getPopup();
        popup.innerHTML = html;
        popup.style.display = 'block';

        var rect = popup.getBoundingClientRect();
        var left = Math.max(6, Math.min(x + 12, window.innerWidth - rect.width - 6));
        var top = Math.max(6, Math.min(y + 12, window.innerHeight - rect.height - 6));
        popup.style.left = left + 'px';
        popup.style.top = top + 'px';
    }

    function hidePopup() {
        clearTimeout(state.hideTimer);
        clearTimeout(state.fetchTimer);
        if (state.popup) state.popup.style.display = 'none';
        state.link = null;
    }

    /* ---------- helpers ---------- */
    function getFileTitle(link) {
        var title = link.getAttribute('title') || link.getAttribute('data-title') || '';
        var m = title.match(/^(File|Image):(.+)$/i);
        if (m) return m[1] + ':' + m[2];
        // 后备:从 href 解析
        var href = link.getAttribute('href') || '';
        m = href.match(/[?&]title=(File|Image):([^&]+)/);
        if (m) return m[1] + ':' + decodeURIComponent(m[2].replace(/\+/g, ' '));
        return null;
    }

    function isFileLink(link) {
        if (!link || !link.href) return false;
        var title = link.getAttribute('title') || '';
        if (/^(File|Image):/i.test(title)) return true;
        return /[?&]title=(File|Image):/i.test(link.search || link.href);
    }

    function fmtBytes(b) {
        if (!b) return '?';
        if (b < 1024) return b + ' B';
        if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
        return (b / 1048576).toFixed(1) + ' MB';
    }

    function esc(s) {
        return mw.html.escape(String(s));
    }

    /* ---------- API ---------- */
    function fetchFileData(fileTitle) {
        var api = new mw.Api();
        return api.get({
            action: 'query',
            titles: fileTitle,
            prop: 'categories|templates|imageinfo|info',
            cllimit: 50,
            tllimit: 50,
            clprop: 'sortkey',
            iiprop: 'url|size|user|comment|timestamp|extmetadata',
            iiurlwidth: CONFIG.thumbWidth,
            format: 'json'
        });
    }

    /* ---------- render ---------- */
    function renderPopup(data, x, y) {
        var pages = data.query && data.query.pages;
        if (!pages) { showPopup(x, y, '<div style="color:#c00;">无法获取数据</div>'); return; }

        var id = Object.keys(pages)[0];
        var page = pages[id];
        if (id === '-1' || !page) {
            showPopup(x, y, '<div style="color:#888;">文件页面不存在</div>');
            return;
        }

        var parts = [];
        parts.push('<div style="font-weight:bold;font-size:14px;margin-bottom:6px;word-break:break-all;">' + esc(page.title) + '</div>');

        // 缩略图
        var ii = page.imageinfo && page.imageinfo[0];
        if (ii) {
            if (ii.thumburl) {
                parts.push('<div style="text-align:center;margin-bottom:8px;"><a href="' + esc(ii.url) + '" target="_blank">' +
                    '<img src="' + esc(ii.thumburl) + '" alt="" style="max-width:100%;border:1px solid #eaecf0;border-radius:3px;"></a></div>');
            }
            var meta = [];
            if (ii.width && ii.height) meta.push(ii.width + '×' + ii.height + ' (' + fmtBytes(ii.size) + ')');
            if (ii.user) meta.push('上传者: ' + esc(ii.user));
            if (meta.length) parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">' + meta.join(' | ') + '</div>');

            // 许可信息 (extmetadata)
            if (ii.extmetadata) {
                var licField = ii.extmetadata.LicenseShortName || ii.extmetadata.License || ii.extmetadata.Copyright;
                if (licField) {
                    var licVal = licField.value || licField;
                    parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">许可: ' + esc(String(licVal)) + '</div>');
                }
            }
        }

        // 分类
        if (page.categories && page.categories.length) {
            parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">分类</div>');
            parts.push('<ul style="margin:2px 0 0 14px;padding:0;list-style:none;">');
            var cats = page.categories.slice(0, 25);
            cats.forEach(function (c) {
                parts.push('<li style="padding:1px 0;font-size:12px;">• ' + esc(c.title.replace(/^Category:/, '')) + '</li>');
            });
            if (page.categories.length > 25) {
                parts.push('<li style="color:#888;font-size:12px;">… 还有 ' + (page.categories.length - 25) + ' 个</li>');
            }
            parts.push('</ul>');
        } else {
            parts.push('<div style="margin-top:6px;color:#888;font-size:12px;">无分类</div>');
        }

        // 模板(含版权模板)
        if (page.templates && page.templates.length) {
            parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">模板 (含版权/许可)</div>');
            parts.push('<div style="margin:2px 0 0;max-height:130px;overflow-y:auto;">');
            var tpls = page.templates.slice(0, 40);
            tpls.forEach(function (t) {
                var name = t.title.replace(/^Template:/, '');
                parts.push('<span style="display:inline-block;background:#f0f2f5;border:1px solid #d4d8dd;border-radius:3px;padding:1px 5px;margin:1px 2px;font:12px monospace;color:#333;">{{' + esc(name) + '}}</span>');
            });
            if (page.templates.length > 40) {
                parts.push('<span style="color:#888;font-size:11px;"> …+' + (page.templates.length - 40) + '</span>');
            }
            parts.push('</div>');
        }

        // 底部链接
        var url = mw.config.get('wgScript') || '/wiki/';
        var target = page.title.replace(/ /g, '_');
        parts.push('<div style="margin-top:10px;border-top:1px solid #eaecf0;padding-top:6px;text-align:right;font-size:12px;">' +
            '<a href="' + url + esc(target) + '" target="_blank">打开文件页 →</a></div>');

        showPopup(x, y, parts.join(''));
    }

    /* ---------- hover handlers ---------- */
    function onLinkOver(e) {
        var link = e.target.closest ? e.target.closest('a[href]') : null;
        if (!link || !isFileLink(link)) return;
        if (link === state.link) return;

        clearTimeout(state.fetchTimer);
        clearTimeout(state.hideTimer);
        state.link = link;

        var title = getFileTitle(link);
        if (!title) return;

        state.fetchTimer = setTimeout(function () {
            showPopup(e.clientX, e.clientY, '<div style="color:#888;font-size:13px;">加载中… ' + esc(title) + '</div>');
            fetchFileData(title).then(function (data) {
                if (state.link === link) renderPopup(data, e.clientX, e.clientY);
            }).catch(function (err) {
                if (state.link === link) showPopup(e.clientX, e.clientY, '<div style="color:#c00;">请求失败: ' + esc(String(err)) + '</div>');
            });
        }, CONFIG.delay);
    }

    function onLinkOut(e) {
        var link = e.target.closest ? e.target.closest('a[href]') : null;
        if (!link || !isFileLink(link)) return;
        if (link !== state.link) return;

        // 如果鼠标进入浮窗则不隐藏
        clearTimeout(state.fetchTimer);
        state.hideTimer = setTimeout(function () {
            hidePopup();
        }, 200);
    }

    /* ---------- install ---------- */
    document.addEventListener('mouseover', onLinkOver, true);
    document.addEventListener('mouseout', onLinkOut, true);

    // 滚动时隐藏
    window.addEventListener('scroll', function () { hidePopup(); }, true);
});