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

用户:Hehua/filepreview.js:修订间差异

来自Postal Wiki
无编辑摘要
[IPE-NEXT] Quick edit
 
(未显示同一用户的5个中间版本)
第11行: 第11行:
  *  或通过 importScript('User:你的名字/FilePreview.js'); 引入
  *  或通过 importScript('User:你的名字/FilePreview.js'); 引入
  */
  */
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.user'], function () {
(function () {
     'use strict';
     'use strict';


     console.log('[FilePreview] 脚本已加载');
     if (!window.mw || !mw.loader) return;
    mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {


    var CONFIG = {
        var CONFIG = {
        delay: 400,
            delay: 400,
        thumbWidth: 220,
            maxPopupWidth: 480,
         maxPopupWidth: 460,
            maxPopupHeight: 540
        maxPopupHeight: 520
         };
    };
        var state = { fetchTimer: null, hideTimer: null, popup: null };


    var state = {
        /* ---------- localized namespace for File (NS 6) ---------- */
         link: null,
        function getFileNSNames() {
         fetchTimer: null,
            var names = ['File', 'Image'];
         hideTimer: null,
            var fmt = mw.config.get('wgFormattedNamespaces') || {};
         popup: null
            if (fmt[6] && names.indexOf(fmt[6]) === -1) names.push(fmt[6]);
    };
            var aliases = mw.config.get('wgNamespaceAliases') || {};
            for (var a in aliases) { if (aliases[a] === 6 && names.indexOf(a) === -1) names.push(a); }
            return names;
         }
         var fileNSNames = getFileNSNames();
         var fileNSRe = new RegExp('^(' + fileNSNames.map(function (n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + '):(.+)$', 'i');
         var fileCSSsel = fileNSNames.map(function (n) { return 'a[title^="' + n + ':"]'; }).join(',');


    /* ---------- utils ---------- */
        /* ---------- utils ---------- */
    function isFileLink(link) {
        function esc(s) { return mw.html.escape(String(s)); }
        if (!link || !link.href) return false;
         function fmtBytes(b) {
        var title = link.getAttribute('title') || '';
            if (!b) return '?';
        if (/^(File|Image):/i.test(title)) return true;
            if (b < 1024) return b + ' B';
        return /[?&]title=(File|Image):/i.test(link.href);
            if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
    }
            return (b / 1048576).toFixed(1) + ' MB';
 
    function getFileTitle(link) {
        var title = link.getAttribute('title') || '';
        var m = title.match(/^(File|Image):(.+)$/i);
        if (m) return m[1] + ':' + m[2];
        var href = link.getAttribute('href') || '';
        m = href.match(/[?&]title=(File|Image):([^&]+)/);
         if (m) return m[1] + ':' + decodeURIComponent(m[2].replace(/\+/g, ' '));
        return null;
    }
 
    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));
    }
 
    /* ---------- popup ---------- */
    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'
            ].join(';');
            document.body.appendChild(el);
            state.popup = el;
 
            el.addEventListener('mouseenter', function () {
                clearTimeout(state.hideTimer);
                clearTimeout(state.fetchTimer);
            });
            el.addEventListener('mouseleave', function (e) {
                if (state.link && e.relatedTarget && state.link.contains(e.relatedTarget)) return;
                hidePopup();
            });
         }
         }
         return state.popup;
         function getFileTitle(link) {
    }
            var t = link.getAttribute('title') || '';
 
            var m = t.match(fileNSRe);
    function showPopup(x, y, html) {
            if (m) return 'File:' + m[2];
        var popup = getPopup();
            var h = link.getAttribute('href') || '';
        popup.innerHTML = html;
             m = h.match(/[?&]title=(File|Image):([^&]+)/);
        popup.style.display = 'block';
            if (m) return 'File:' + decodeURIComponent(m[2].replace(/\+/g, ' '));
        var rect = popup.getBoundingClientRect();
             return null;
        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;
    }
 
    /* ---------- API ---------- */
    function fetchFileData(fileTitle) {
        var api = new mw.Api();
        return api.get({
            action: 'query',
            titles: fileTitle,
            prop: 'categories|templates|imageinfo',
            cllimit: 50,
            tllimit: 50,
            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 = [];
         /* ---------- popup ---------- */
        parts.push('<div style="font-weight:bold;font-size:14px;margin-bottom:6px;word-break:break-all;">' + esc(page.title) + '</div>');
         function getPopup() {
 
             if (!state.popup) {
        var ii = page.imageinfo && page.imageinfo[0];
                 var el = document.createElement('div');
         if (ii) {
                el.id = 'filepreview-popup';
             if (ii.thumburl) {
                el.style.cssText = [
                 parts.push('<div style="text-align:center;margin-bottom:8px;"><a href="' + esc(ii.url) + '" target="_blank">' +
                    'display:none;position:fixed;z-index:9999',
                     '<img src="' + esc(ii.thumburl) + '" alt="" style="max-width:100%;border:1px solid #eaecf0;border-radius:3px;"></a></div>');
                    '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'
                ].join(';');
                document.body.appendChild(el);
                state.popup = el;
                el.addEventListener('mouseenter', function () { clearTimeout(state.hideTimer); });
                el.addEventListener('mouseleave', function () { state.hideTimer = setTimeout(hidePopup, 300); });
             }
             }
             var meta = [];
             return state.popup;
            if (ii.width && ii.height) meta.push(ii.width + '\u00d7' + ii.height + ' (' + fmtBytes(ii.size) + ')');
        }
            if (ii.user) meta.push('\u4e0a\u4f20\u8005: ' + esc(ii.user));
            if (meta.length) parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">' + meta.join(' | ') + '</div>');


            if (ii.extmetadata) {
        function showPopup(link, x, y, html) {
                var licField = ii.extmetadata.LicenseShortName || ii.extmetadata.License || ii.extmetadata.Copyright;
            var popup = getPopup();
                if (licField) {
            popup.innerHTML = html;
                    var licVal = licField.value || licField;
            popup.style.display = 'block';
                    parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">\u8bb8\u53ef: ' + esc(String(licVal)) + '</div>');
            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';
         }
         }


         // categories
         function hidePopup() {
        if (page.categories && page.categories.length) {
             clearTimeout(state.hideTimer);
             parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">\u5206\u7c7b</div>');
             clearTimeout(state.fetchTimer);
            parts.push('<ul style="margin:2px 0 0 14px;padding:0;list-style:none;">');
             if (state.popup) state.popup.style.display = 'none';
             page.categories.slice(0, 25).forEach(function (c) {
                parts.push('<li style="padding:1px 0;font-size:12px;">\u2022 ' + esc(c.title.replace(/^Category:/, '')) + '</li>');
            });
             if (page.categories.length > 25) {
                parts.push('<li style="color:#888;font-size:12px;">\u2026 \u8fd8\u6709 ' + (page.categories.length - 25) + ' \u4e2a</li>');
            }
            parts.push('</ul>');
        } else {
            parts.push('<div style="margin-top:6px;color:#888;font-size:12px;">\u65e0\u5206\u7c7b</div>');
         }
         }


         // templates
         /* ---------- api + render ---------- */
         if (page.templates && page.templates.length) {
         function fetchData(title) {
             parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">\u6a21\u677f (\u542b\u7248\u6743/\u8bb8\u53ef)</div>');
             return new mw.Api().get({
            parts.push('<div style="margin:2px 0 0;max-height:130px;overflow-y:auto;">');
                action: 'query',
            page.templates.slice(0, 40).forEach(function (t) {
                titles: title,
                 var name = t.title.replace(/^Template:/, '');
                prop: 'categories|templates|imageinfo',
                 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>');
                cllimit: 50,
                 tllimit: 50,
                iiprop: 'url|size|user|comment|timestamp|extmetadata',
                 format: 'json'
             });
             });
            if (page.templates.length > 40) {
                parts.push('<span style="color:#888;font-size:11px;"> \u2026+' + (page.templates.length - 40) + '</span>');
            }
            parts.push('</div>');
         }
         }


         // footer link
         function render(data, link, x, y) {
        parts.push('<div style="margin-top:10px;border-top:1px solid #eaecf0;padding-top:6px;text-align:right;font-size:12px;">' +
            var pages = data.query && data.query.pages;
             '<a href="' + mw.util.getUrl(page.title) + '" target="_blank">\u6253\u5f00\u6587\u4ef6\u9875 \u2192</a></div>');
            if (!pages) { showPopup(link, x, y, '<div style="color:#c00;">\u65e0\u6cd5\u83b7\u53d6\u6570\u636e</div>'); return; }
             var id = Object.keys(pages)[0], page = pages[id];
            if (id === '-1' || !page) { showPopup(link, x, y, '<div style="color:#888;">\u6587\u4ef6\u9875\u4e0d\u5b58\u5728</div>'); return; }


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


    /* ---------- event handlers ---------- */
            var ii = page.imageinfo && page.imageinfo[0];
    var currentMouseX = 0, currentMouseY = 0;
            if (ii) {
                var meta = [];
                if (ii.width && ii.height) meta.push(ii.width + '\u00d7' + ii.height + ' (' + fmtBytes(ii.size) + ')');
                if (ii.user) meta.push('\u4e0a\u4f20\u8005: ' + esc(ii.user));
                if (meta.length) parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">' + meta.join(' | ') + '</div>');
                if (ii.extmetadata) {
                    var lf = ii.extmetadata.LicenseShortName || ii.extmetadata.License || ii.extmetadata.Copyright;
                    if (lf) parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">\u8bb8\u53ef: ' + esc(String(lf.value || lf)) + '</div>');
                }
            }


    document.addEventListener('mousemove', function (e) {
            if (page.categories && page.categories.length) {
        currentMouseX = e.clientX;
                parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">\u5206\u7c7b</div><ul style="margin:2px 0 0 14px;padding:0;">');
        currentMouseY = e.clientY;
                page.categories.slice(0, 25).forEach(function (c) {
    }, true);
                    parts.push('<li style="padding:1px 0;font-size:12px;">\u2022 ' + esc(c.title.replace(/^Category:/, '')) + '</li>');
                });
                if (page.categories.length > 25) parts.push('<li style="color:#888;font-size:12px;">\u2026\u8fd8\u6709' + (page.categories.length - 25) + '\u4e2a</li>');
                parts.push('</ul>');
            } else {
                parts.push('<div style="margin-top:6px;color:#888;font-size:12px;">\u65e0\u5206\u7c7b</div>');
            }


    function onLinkOver(e) {
            if (page.templates && page.templates.length) {
        var link = e.target.closest ? e.target.closest('a') : null;
                parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">\u6a21\u677f(\u542b\u7248\u6743/\u8bb8\u53ef)</div>');
        if (!link || !isFileLink(link)) return;
                parts.push('<div style="margin:2px 0 0;max-height:140px;overflow-y:auto;">');
        if (link === state.link) return;
                page.templates.slice(0, 40).forEach(function (t) {
                    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(t.title.replace(/^Template:/, '')) + '}}</span>');
                });
                if (page.templates.length > 40) parts.push('<span style="color:#888;font-size:11px;">\u2026+' + (page.templates.length - 40) + '</span>');
                parts.push('</div>');
            }


        clearTimeout(state.fetchTimer);
            parts.push('<div style="margin-top:10px;border-top:1px solid #eaecf0;padding-top:6px;text-align:right;font-size:12px;">' +
        clearTimeout(state.hideTimer);
                '<a href="' + mw.util.getUrl(page.title) + '" target="_blank">\u6253\u5f00\u6587\u4ef6\u9875 \u2192</a></div>');
        state.link = link;


        var title = getFileTitle(link);
            showPopup(link, x, y, parts.join(''));
        if (!title) {
            console.log('[FilePreview] 无法解析文件标题:', link.href);
            return;
         }
         }


         console.log('[FilePreview] 悬停:', title);
         /* ---------- bind ---------- */
        function bindLink(link) {
            if (link._filePreview) return;
            link._filePreview = true;


        var mx = currentMouseX, my = currentMouseY;
            link.addEventListener('mouseenter', function (e) {
        state.fetchTimer = setTimeout(function () {
                clearTimeout(state.fetchTimer);
            showPopup(mx, my, '<div style="color:#888;font-size:13px;">\u52a0\u8f7d\u4e2d\u2026 ' + esc(title) + '</div>');
                clearTimeout(state.hideTimer);
            fetchFileData(title).then(function (data) {
                var title = getFileTitle(link);
                if (state.link === link) renderPopup(data, mx, my);
                if (!title) return;
            }).catch(function (err) {
                var x = e.clientX, y = e.clientY;
                console.log('[FilePreview] API 错误:', err);
                state.fetchTimer = setTimeout(function () {
                if (state.link === link) showPopup(mx, my, '<div style="color:#c00;">\u8bf7\u6c42\u5931\u8d25: ' + esc(String(err)) + '</div>');
                    showPopup(link, x, y, '<div style="color:#888;">\u52a0\u8f7d\u4e2d\u2026 ' + esc(title) + '</div>');
                    fetchData(title).then(function (d) { render(d, link, x, y); })
                        .catch(function (err) { showPopup(link, x, y, '<div style="color:#c00;">\u5931\u8d25: ' + esc(String(err)) + '</div>'); });
                }, CONFIG.delay);
             });
             });
        }, CONFIG.delay);
    }


    function onLinkOut(e) {
            link.addEventListener('mouseleave', function () {
        var link = e.target.closest ? e.target.closest('a') : null;
                clearTimeout(state.fetchTimer);
        if (!link || !isFileLink(link) || link !== state.link) return;
                state.hideTimer = setTimeout(hidePopup, 200);
            });
        }


         // 如果鼠标移到了 <a> 内部的子元素(如 <img>),不隐藏
         function scanLinks() {
        if (e.relatedTarget && link.contains(e.relatedTarget)) return;
             document.querySelectorAll(fileCSSsel).forEach(bindLink);
 
        }
        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);


    console.log('[FilePreview] 事件监听已安装');
        /* ---------- init ---------- */
});
        var observer = new MutationObserver(scanLinks);
        observer.observe(document.body, { childList: true, subtree: true });
        scanLinks();
        window.addEventListener('scroll', hidePopup, true);
    });
})();

2026年7月23日 (四) 07:07的最新版本

/**
 * FilePreview — 文件链接悬停预览脚本
 * 鼠标悬停在 File: 蓝色链接上时,弹出浮窗显示:
 *   - 缩略图预览
 *   - 文件信息(尺寸、大小、上传者)
 *   - 分类 (categories)
 *   - 版权/许可模板 (templates)
 *
 * 安装方式:
 *   将本脚本的内容复制到 User:<你的名字>/common.js
 *   或通过 importScript('User:你的名字/FilePreview.js'); 引入
 */
(function () {
    'use strict';

    if (!window.mw || !mw.loader) return;
    mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {

        var CONFIG = {
            delay: 400,
            maxPopupWidth: 480,
            maxPopupHeight: 540
        };
        var state = { fetchTimer: null, hideTimer: null, popup: null };

        /* ---------- localized namespace for File (NS 6) ---------- */
        function getFileNSNames() {
            var names = ['File', 'Image'];
            var fmt = mw.config.get('wgFormattedNamespaces') || {};
            if (fmt[6] && names.indexOf(fmt[6]) === -1) names.push(fmt[6]);
            var aliases = mw.config.get('wgNamespaceAliases') || {};
            for (var a in aliases) { if (aliases[a] === 6 && names.indexOf(a) === -1) names.push(a); }
            return names;
        }
        var fileNSNames = getFileNSNames();
        var fileNSRe = new RegExp('^(' + fileNSNames.map(function (n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + '):(.+)$', 'i');
        var fileCSSsel = fileNSNames.map(function (n) { return 'a[title^="' + n + ':"]'; }).join(',');

        /* ---------- utils ---------- */
        function esc(s) { return mw.html.escape(String(s)); }
        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 getFileTitle(link) {
            var t = link.getAttribute('title') || '';
            var m = t.match(fileNSRe);
            if (m) return 'File:' + m[2];
            var h = link.getAttribute('href') || '';
            m = h.match(/[?&]title=(File|Image):([^&]+)/);
            if (m) return 'File:' + decodeURIComponent(m[2].replace(/\+/g, ' '));
            return null;
        }

        /* ---------- popup ---------- */
        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'
                ].join(';');
                document.body.appendChild(el);
                state.popup = el;
                el.addEventListener('mouseenter', function () { clearTimeout(state.hideTimer); });
                el.addEventListener('mouseleave', function () { state.hideTimer = setTimeout(hidePopup, 300); });
            }
            return state.popup;
        }

        function showPopup(link, 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';
        }

        /* ---------- api + render ---------- */
        function fetchData(title) {
            return new mw.Api().get({
                action: 'query',
                titles: title,
                prop: 'categories|templates|imageinfo',
                cllimit: 50,
                tllimit: 50,
                iiprop: 'url|size|user|comment|timestamp|extmetadata',
                format: 'json'
            });
        }

        function render(data, link, x, y) {
            var pages = data.query && data.query.pages;
            if (!pages) { showPopup(link, x, y, '<div style="color:#c00;">\u65e0\u6cd5\u83b7\u53d6\u6570\u636e</div>'); return; }
            var id = Object.keys(pages)[0], page = pages[id];
            if (id === '-1' || !page) { showPopup(link, x, y, '<div style="color:#888;">\u6587\u4ef6\u9875\u4e0d\u5b58\u5728</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) {
                var meta = [];
                if (ii.width && ii.height) meta.push(ii.width + '\u00d7' + ii.height + ' (' + fmtBytes(ii.size) + ')');
                if (ii.user) meta.push('\u4e0a\u4f20\u8005: ' + esc(ii.user));
                if (meta.length) parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">' + meta.join(' | ') + '</div>');
                if (ii.extmetadata) {
                    var lf = ii.extmetadata.LicenseShortName || ii.extmetadata.License || ii.extmetadata.Copyright;
                    if (lf) parts.push('<div style="color:#555;margin-bottom:4px;font-size:12px;">\u8bb8\u53ef: ' + esc(String(lf.value || lf)) + '</div>');
                }
            }

            if (page.categories && page.categories.length) {
                parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">\u5206\u7c7b</div><ul style="margin:2px 0 0 14px;padding:0;">');
                page.categories.slice(0, 25).forEach(function (c) {
                    parts.push('<li style="padding:1px 0;font-size:12px;">\u2022 ' + esc(c.title.replace(/^Category:/, '')) + '</li>');
                });
                if (page.categories.length > 25) parts.push('<li style="color:#888;font-size:12px;">\u2026\u8fd8\u6709' + (page.categories.length - 25) + '\u4e2a</li>');
                parts.push('</ul>');
            } else {
                parts.push('<div style="margin-top:6px;color:#888;font-size:12px;">\u65e0\u5206\u7c7b</div>');
            }

            if (page.templates && page.templates.length) {
                parts.push('<div style="margin-top:8px;font-weight:bold;font-size:13px;">\u6a21\u677f(\u542b\u7248\u6743/\u8bb8\u53ef)</div>');
                parts.push('<div style="margin:2px 0 0;max-height:140px;overflow-y:auto;">');
                page.templates.slice(0, 40).forEach(function (t) {
                    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(t.title.replace(/^Template:/, '')) + '}}</span>');
                });
                if (page.templates.length > 40) parts.push('<span style="color:#888;font-size:11px;">\u2026+' + (page.templates.length - 40) + '</span>');
                parts.push('</div>');
            }

            parts.push('<div style="margin-top:10px;border-top:1px solid #eaecf0;padding-top:6px;text-align:right;font-size:12px;">' +
                '<a href="' + mw.util.getUrl(page.title) + '" target="_blank">\u6253\u5f00\u6587\u4ef6\u9875 \u2192</a></div>');

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

        /* ---------- bind ---------- */
        function bindLink(link) {
            if (link._filePreview) return;
            link._filePreview = true;

            link.addEventListener('mouseenter', function (e) {
                clearTimeout(state.fetchTimer);
                clearTimeout(state.hideTimer);
                var title = getFileTitle(link);
                if (!title) return;
                var x = e.clientX, y = e.clientY;
                state.fetchTimer = setTimeout(function () {
                    showPopup(link, x, y, '<div style="color:#888;">\u52a0\u8f7d\u4e2d\u2026 ' + esc(title) + '</div>');
                    fetchData(title).then(function (d) { render(d, link, x, y); })
                        .catch(function (err) { showPopup(link, x, y, '<div style="color:#c00;">\u5931\u8d25: ' + esc(String(err)) + '</div>'); });
                }, CONFIG.delay);
            });

            link.addEventListener('mouseleave', function () {
                clearTimeout(state.fetchTimer);
                state.hideTimer = setTimeout(hidePopup, 200);
            });
        }

        function scanLinks() {
            document.querySelectorAll(fileCSSsel).forEach(bindLink);
        }

        /* ---------- init ---------- */
        var observer = new MutationObserver(scanLinks);
        observer.observe(document.body, { childList: true, subtree: true });
        scanLinks();
        window.addEventListener('scroll', hidePopup, true);
    });
})();