Fandom 图片下载器(选悬浮标识)

在 Fandom wiki 网站上,当鼠标悬停在图片上时显示一个下载按钮,点击即可下载原图。采用JS动态计算尺寸,彻底修复图标变形问题。

// ==UserScript==
// @name         Fandom 图片下载器(选悬浮标识)
// @name:en      Fandom Image Hover Downloader
// @namespace    https://github.com/your-username-here
// @version      1.3
// @description  在 Fandom wiki 网站上,当鼠标悬停在图片上时显示一个下载按钮,点击即可下载原图。采用JS动态计算尺寸,彻底修复图标变形问题。
// @description:en Shows a download button on mouse hover over images on Fandom wiki sites. Uses JS dynamic sizing to definitively fix icon distortion.
// @author       Gemini & Camellia895
// @match        *://*.fandom.com/*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 全局常量 ---
    const DOWNLOAD_SVG_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="60%" height="60%"><path d="M12 15.5l-5-5h3V2h4v8.5h3l-5 5zM5 20h14v-2H5v2z"></path></svg>`;

    // --- 工具函数: 节流 ---
    function throttle(func, limit) {
        let inThrottle;
        return function() {
            const args = arguments;
            const context = this;
            if (!inThrottle) {
                func.apply(context, args);
                inThrottle = true;
                setTimeout(() => inThrottle = false, limit);
            }
        };
    }

    // --- 核心功能 ---

    function getOriginalImageUrl(url) {
        const match = url.match(/^(.*?\.(?:png|jpg|jpeg|gif|webp|bmp|svg))/i);
        return match ? match[1] : url.split('/revision/')[0] || url;
    }

    function getFilenameFromUrl(url) {
        try {
            return decodeURIComponent(url.split('/').pop());
        } catch (e) {
            return url.split('/').pop();
        }
    }

    async function downloadFile(url, filename) {
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error(`网络响应错误: ${response.statusText}`);
            const blob = await response.blob();
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(a.href);
        } catch (error) {
            console.error('下载失败:', error);
            alert(`下载失败: ${filename}\n原因: ${error.message}`);
        }
    }

    /**
     * 为单个图片元素添加悬浮下载功能。
     * @param {HTMLImageElement} imgElement - 目标图片元素。
     */
    function addHoverDownloader(imgElement) {
        if (imgElement.dataset.hoverDownloaderAdded) return;
        imgElement.dataset.hoverDownloaderAdded = 'true';

        const container = imgElement.closest('a') || imgElement.parentElement;
        if (!container) return;

        if (window.getComputedStyle(container).position === 'static') {
            container.style.position = 'relative';
        }

        container.addEventListener('mouseenter', () => {
            if (container.querySelector('.hover-download-overlay-gemini')) return;

            const overlay = document.createElement('div');
            overlay.className = 'hover-download-overlay-gemini';
            overlay.style.cssText = `
                position: absolute; top: 0; left: 0; width: 100%; height: 100%;
                background-color: rgba(0, 0, 0, 0.45);
                display: flex; align-items: center; justify-content: center;
                cursor: pointer; z-index: 9999;
                transition: opacity 0.2s ease;
            `;

            const iconContainer = document.createElement('div');
            iconContainer.style.cssText = `
                /* 外观和居中样式 */
                background-color: rgba(0, 0, 0, 0.6);
                border-radius: 50%;
                border: 2px solid white;
                box-sizing: border-box;
                display: flex;
                align-items: center;
                justify-content: center;
            `;

            // [v1.3] 核心修复:使用 JavaScript 动态计算尺寸
            // 1. 获取容器的实际渲染尺寸
            const containerWidth = container.clientWidth;
            const containerHeight = container.clientHeight;
            // 2. 找到较短的一边
            const shorterSide = Math.min(containerWidth, containerHeight);
            // 3. 将图标尺寸设置为较短边的 50%,确保为正方形且不变形
            const iconSize = shorterSide * 0.5;

            iconContainer.style.width = `${iconSize}px`;
            iconContainer.style.height = `${iconSize}px`;
            // 确保图标在极小图片上不会过小
            iconContainer.style.minWidth = '35px';
            iconContainer.style.minHeight = '35px';


            iconContainer.innerHTML = DOWNLOAD_SVG_ICON;
            overlay.appendChild(iconContainer);

            overlay.onclick = async (e) => {
                e.preventDefault();
                e.stopPropagation();

                const imageUrl = imgElement.src || imgElement.dataset.src;
                const originalUrl = getOriginalImageUrl(imageUrl);
                const filename = getFilenameFromUrl(originalUrl);

                iconContainer.innerHTML = '...';
                await downloadFile(originalUrl, filename);
                if(overlay.parentElement) overlay.remove();
            };

            container.appendChild(overlay);
        });

        container.addEventListener('mouseleave', () => {
            const activeOverlay = container.querySelector('.hover-download-overlay-gemini');
            if (activeOverlay) activeOverlay.remove();
        });
    }

    /**
     * 扫描整个页面,为所有符合条件的图片添加下载功能。
     */
    function processPage() {
        const images = document.querySelectorAll('img[src*="static.wikia.nocookie.net/"]:not([data-hover-downloader-added])');
        images.forEach(img => {
            if (img.offsetParent !== null && (img.clientWidth > 30 && img.clientHeight > 30)) {
                 addHoverDownloader(img);
            }
        });
    }

    // --- 主执行逻辑 ---
    const throttledProcessPage = throttle(processPage, 500);
    const observer = new MutationObserver(throttledProcessPage);
    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['src']
    });

    window.addEventListener('load', () => {
        throttledProcessPage();
        setTimeout(throttledProcessPage, 1000);
    });

})();