Greasy Fork

Greasy Fork is available in English.

悬停预览

悬停链接打开小窗并优化资源管理。重用打开的小窗以节省资源,同时保留其位置和大小。包含悬停时间和窗口大小设置,并改进了链接预取功能和资源管理效率。修复了鼠标移动到小窗时意外关闭的问题。支持 URL 路径和链接属性的排除。

当前为 2024-09-11 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         悬停预览
// @version      3.8
// @description  悬停链接打开小窗并优化资源管理。重用打开的小窗以节省资源,同时保留其位置和大小。包含悬停时间和窗口大小设置,并改进了链接预取功能和资源管理效率。修复了鼠标移动到小窗时意外关闭的问题。支持 URL 路径和链接属性的排除。
// @author       hiisme
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @namespace http://greasyfork.icu/users/217852
// ==/UserScript==

(function () {
    'use strict';

    let hoverTimeoutId = null;
    let prefetchTimeoutId = null;
    let popupWindow = null;
    let popupWindowRect = null;
    let isMouseOverPopup = false;

    // 读取设置或设置默认值
    const hoverDelay = GM_getValue('hoverDelay', 1200);
    const windowWidth = GM_getValue('windowWidth', 690);
    const windowHeight = GM_getValue('windowHeight', 400);

    // 注册菜单命令以更改设置
    GM_registerMenuCommand('设置悬停延迟时间 (毫秒)', () => {
        const delay = prompt('请输入悬停延迟时间(毫秒):', hoverDelay);
        if (delay !== null) {
            const parsedDelay = parseInt(delay, 10) || 1200;
            GM_setValue('hoverDelay', parsedDelay);
            alert(`悬停延迟时间设置为 ${parsedDelay} 毫秒。`);
        }
    });

    GM_registerMenuCommand('设置小窗大小', () => {
        const width = prompt('请输入窗口宽度:', windowWidth);
        const height = prompt('请输入窗口高度:', windowHeight);
        if (width !== null && height !== null) {
            const parsedWidth = parseInt(width, 10) || 690;
            const parsedHeight = parseInt(height, 10) || 400;
            GM_setValue('windowWidth', parsedWidth);
            GM_setValue('windowHeight', parsedHeight);
            alert(`窗口大小设置为 ${parsedWidth}x${parsedHeight}。`);
        }
    });

    // 排除链接类型配置
    const excludedLinkPatterns = GM_getValue('excludedLinkPatterns', [
        '/logout', '/download', '/pdf', '/doc', '/xls', '/zip', '/rar',
        '.pdf', '.doc', '.xls', '.zip', '.rar', '.7z',
        'mailto:', 'tel:', '#', 'javascript:', 'data:', 'blob:',
        '/login', '/register', '/search', '/settings', '/update', '/change-password',
        '/terms', '/privacy', '/about', '/contact', '/sitemap', '/faq',
        '/checkout', '/cart', '/order', '/confirmation',
        '/profile', '/dashboard', '/user', '/admin', '/management',
        '/help', '/support', '/feedback', '/report', '/complaint',
        '/affiliate', '/sponsored', '/promo', '/ad', '/campaign',
        '/newsletter', '/subscription', '/unsub', '/unsubscribe',
        '/api', '/ajax', '/webhook', '/endpoint', '/graphql',
        '/static', '/assets', '/images', '/videos', '/css', '/js',
        '/terms-of-service', '/cookie-policy', '/legal', '/cookies', '/privacy-policy',
        '/resources', '/docs', '/guides', '/manual', '/tutorial',
        '/event', '/calendar', '/schedule', '/announcement', '/webinar',
        '/login', '/auth', '/oauth', '/signin', '/signup', '/social',
        '/search-results', '/search/?q=', '/search?query=', '/search/?query=',
        '/file', '/files', '/upload', '/downloads', '/saved',
        '/docs/', '/downloads/', '/web/', '/api/', '/service/',
        '/wp-admin', '/wp-login', '/wp-content', '/wp-includes',
        '/wp-json', '/index.php', '/cgi-bin', '/phpmyadmin',
        '/admin/', '/admin.php', '/admin_panel', '/admin_area'
    ]);

    const isExcludedLink = (href) => {
        return excludedLinkPatterns.some(pattern => href.includes(pattern));
    };

    // 预取链接
    const prefetchLink = async (url) => {
        clearTimeout(prefetchTimeoutId);
        document.querySelectorAll(`.tm-prefetch[href="${url}"]`).forEach(link => link.remove());

        return new Promise((resolve) => {
            const linkElement = document.createElement('link');
            linkElement.rel = 'prefetch';
            linkElement.href = url;
            linkElement.className = 'tm-prefetch';
            linkElement.onload = () => resolve(true);
            linkElement.onerror = () => resolve(false);
            document.head.appendChild(linkElement);
        });
    };

    // 创建或更新小窗
    const createOrUpdatePopupWindow = async (url, x, y) => {
        if (popupWindow && !popupWindow.closed) {
            if (popupWindow.location.href !== url) {
                popupWindow.location.href = url;
            }
            popupWindow.moveTo(x, y);
        } else {
            popupWindow = window.open(url, 'popupWindow', `width=${windowWidth},height=${windowHeight},top=${y},left=${x},scrollbars=yes,resizable=yes`);

            // 立即注册事件监听器,确保即使页面尚未加载完成也不会意外关闭
            popupWindow.addEventListener('mouseover', () => {
                isMouseOverPopup = true;
            });

            popupWindow.addEventListener('mouseout', () => {
                isMouseOverPopup = false;
                closePopupWindow();
            });
        }

        if (popupWindow) {
            // 等待页面加载完成以获取悬浮窗位置和大小
            await new Promise((resolve) => {
                popupWindow.addEventListener('load', () => {
                    popupWindowRect = {
                        left: popupWindow.screenX,
                        top: popupWindow.screenY,
                        right: popupWindow.screenX + popupWindow.innerWidth,
                        bottom: popupWindow.screenY + popupWindow.innerHeight
                    };
                    resolve();
                });
            });
        }
    };

    // 关闭小窗
    const closePopupWindow = () => {
        if (popupWindow && !popupWindow.closed && !isMouseOverPopup) {
            setTimeout(() => {
                if (!isMouseOverPopup) {
                    popupWindow.close();
                    popupWindow = null;
                    popupWindowRect = null;
                }
            }, 200);
        }
    };

    // 处理鼠标移出事件
    const handleMouseOut = (event) => {
        clearTimeout(hoverTimeoutId);
        clearTimeout(prefetchTimeoutId);

        document.querySelectorAll('.tm-prefetch').forEach(link => link.remove());

        if (popupWindow && !popupWindow.closed && popupWindowRect && !isMouseOverPopup) {
            const { clientX: x, clientY: y } = event;
            const outsidePopupWindow = (
                x < popupWindowRect.left ||
                x > popupWindowRect.right ||
                y < popupWindowRect.top ||
                y > popupWindowRect.bottom
            );

            if (outsidePopupWindow) {
                closePopupWindow();
            }
        }
    };

    // 处理鼠标悬停事件
    const handleMouseOver = async (event) => {
        if (window.name === 'popupWindow') return;

        const linkElement = event.target.closest('a');
        if (linkElement && linkElement.href && !isExcludedLink(linkElement.href)) {
            clearTimeout(hoverTimeoutId);
            clearTimeout(prefetchTimeoutId);

            prefetchTimeoutId = setTimeout(() => prefetchLink(linkElement.href), 200);

            hoverTimeoutId = setTimeout(async () => {
                const { clientX: x, clientY: y } = event;
                await createOrUpdatePopupWindow(linkElement.href, x + 10, y + 10);
            }, hoverDelay);
        }
    };

    // 防抖动函数
    const debounce = (fn, delay) => {
        let timeoutId;
        return (...args) => {
            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => fn.apply(this, args), delay);
        };
    };

    // 节流函数
    const throttle = (fn, limit) => {
        let lastFn, lastRan;
        return (...args) => {
            if (!lastRan) {
                fn.apply(this, args);
                lastRan = Date.now();
            } else {
                clearTimeout(lastFn);
                lastFn = setTimeout(() => {
                    if (Date.now() - lastRan >= limit) {
                        fn.apply(this, args);
                        lastRan = Date.now();
                    }
                }, limit - (Date.now() - lastRan));
            }
        };
    };

    // 处理滚动和点击事件
    const handleDocumentScrollOrClick = throttle(closePopupWindow, 100);

    // 清理资源和事件监听器
    const cleanup = () => {
        clearTimeout(hoverTimeoutId);
        clearTimeout(prefetchTimeoutId);

        document.querySelectorAll('.tm-prefetch').forEach(link => link.remove());

        document.removeEventListener('mouseover', handleMouseOver, true);
        document.removeEventListener('mouseout', handleMouseOut, true);
        document.removeEventListener('scroll', handleDocumentScrollOrClick, true);
        document.removeEventListener('click', handleDocumentScrollOrClick, true);

        closePopupWindow();
    };

    // 注册事件监听器
    const addEventListeners = () => {
        document.addEventListener('mouseover', handleMouseOver, { capture: true, passive: true });
        document.addEventListener('mouseout', handleMouseOut, { capture: true, passive: true });
        document.addEventListener('scroll', handleDocumentScrollOrClick, { capture: true, passive: true });
        document.addEventListener('click', handleDocumentScrollOrClick, true);
    };

    window.addEventListener('beforeunload', cleanup);

    addEventListeners();

})();