Greasy Fork is available in English.

悬停预览

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

// ==UserScript==
// @name         悬停预览
// @version      3.9
// @description  悬停链接打开小窗并优化资源管理。重用打开的小窗以节省资源,同时保留其位置和大小。包含悬停时间和窗口大小设置,并改进了链接预取功能和资源管理效率。修复了鼠标移动到小窗时意外关闭的问题。支持 URL 路径和链接属性的排除。
// @author       hiisme
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @namespace https://greasyfork.org/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', // API、AJAX、Webhooks、端点、GraphQL
        '/static', '/assets', '/images', '/videos', '/css', '/js', // 静态资源、图片、视频、CSS、JS
        '/terms-of-service', '/cookie-policy', '/legal', '/cookies', '/privacy-policy', // 服务条款、Cookie 政策、法律声明、隐私政策
        '/resources', '/docs', '/guides', '/manual', '/tutorial', // 资源、文档、指南、手册、教程
        '/event', '/calendar', '/schedule', '/announcement', '/webinar', // 事件、日历、计划、公告、网络研讨会
        '/login', '/auth', '/oauth', '/signin', '/signup', '/social', // 登录、认证、OAuth、登录、注册、社交登录
        '/search-results', '/search/?q=', '/search?query=', '/search/?query=', // 搜索结果
        '/file', '/files', '/upload', '/downloads', '/saved', // 文件上传、下载、保存
        '/docs/', '/downloads/', '/web/', '/api/', '/service/', // 文件、服务、API 目录
        '/wp-admin', '/wp-login', '/wp-content', '/wp-includes', // WordPress 特有路径
        '/wp-json', '/index.php', '/cgi-bin', '/phpmyadmin', // PHP 和管理路径
        '/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`);
        }

        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();
                });
            });

            // 确保当鼠标进入小窗时不关闭它
            popupWindow.addEventListener('focus', () => {
                isMouseOverPopup = true;
            });

            popupWindow.addEventListener('mousemove', () => {
                isMouseOverPopup = true;
            });
        }
    };

    // 关闭小窗
    const closePopupWindow = () => {
        if (popupWindow && !popupWindow.closed) {
            // 延迟关闭以确认鼠标真的在外面
            setTimeout(() => {
                if (!isMouseOverPopup) {
                    popupWindow.close();
                    popupWindow = null;
                    popupWindowRect = null;
                }
            }, 200); // 延迟时间,确保鼠标不会快速返回
        }
    };

    // 防抖动函数
    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 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);

            // 1000ms 后预取链接
            prefetchTimeoutId = setTimeout(() => prefetchLink(linkElement.href), 1000);

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

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

        // 移除预取链接
        document.querySelectorAll('.tm-prefetch').forEach(link => link.remove());

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

            if (outsidePopupWindow) {
                isMouseOverPopup = false; // 更新状态为 false
                closePopupWindow();
            }
        }
    };

    // 处理窗口聚焦事件
    const handleWindowFocus = () => {
        closePopupWindow();
    };

    // 处理滚动和点击事件
    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);
        window.removeEventListener('focus', handleWindowFocus);
        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 });
        window.addEventListener('focus', handleWindowFocus);
        document.addEventListener('scroll', handleDocumentScrollOrClick, { capture: true, passive: true });
        document.addEventListener('click', handleDocumentScrollOrClick, true);
    };

    // 页面卸载时清理
    window.addEventListener('beforeunload', cleanup);

    // 初始事件监听器
    addEventListeners();

})();