Greasy Fork

Greasy Fork is available in English.

二维码自动解析

鼠标悬停时自动在本地解析二维码

当前为 2025-11-25 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         二维码自动解析
// @description  鼠标悬停时自动在本地解析二维码
// @namespace    http://tampermonkey.net/
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.min.js
// @match        *://*/*
// @grant        GM_setClipboard
// @grant        GM_openInTab
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// @version      1.2
// @author       Gemini
// @license      GPLv3
// ==/UserScript==

(function() {
    'use strict';

    // === 配置 ===
    const DELAY_MS = 500;
    const TOLERANCE = 2;

    // === 全局变量 ===
    let hoverTimer = null;
    let tooltip = null;
    let currentImg = null;

    // 坐标相关
    let lastMouseScreenX = 0;
    let lastMouseScreenY = 0;
    let lastMouseClientX = 0;
    let lastMouseClientY = 0;

    // 顶层窗口的视口偏移量 (Screen坐标 - Client坐标)
    // 用于消除浏览器地址栏、书签栏带来的坐标误差
    let topWinOffset = null;

    // 会话缓存
    const qrCache = new Map();
    const isTop = window.self === window.top;

    // === 样式注入 ===
    GM_addStyle(`
        #qr-custom-tooltip {
            position: fixed;
            z-index: 2147483647;
            background: rgba(0, 0, 0, 0.9);
            color: #fff;
            padding: 8px 12px;
            font-size: 12px;
            max-width: 320px;
            word-break: break-all;
            pointer-events: none;
            display: none;
            border: 1px solid #555;
            border-radius: 0px !important;
            box-shadow: none !important;
            line-height: 1.5;
            text-align: left;
        }
        .qr-detected-style {
            cursor: pointer !important;
            outline: none !important;
        }
    `);

    // ==========================================
    //      通信模块 (跨域支持)
    // ==========================================

    function sendToTop(type, payload = {}) {
        if (isTop) {
            handleMessage({ data: { type, payload } });
        } else {
            window.top.postMessage({ type: 'QR_SCRIPT_MSG', action: type, payload }, '*');
        }
    }

    if (isTop) {
        window.addEventListener('message', (event) => {
            if (event.data && event.data.type === 'QR_SCRIPT_MSG') {
                handleMessage({ data: { type: event.data.action, payload: event.data.payload } });
            }
        });
    }

    function handleMessage(e) {
        const { type, payload } = e.data;
        switch (type) {
            case 'SHOW_TOOLTIP':
                renderTooltip(payload.text, payload.coords, payload.isLink);
                break;
            case 'HIDE_TOOLTIP':
                hideTooltipDOM();
                break;
            case 'SHOW_FEEDBACK':
                showFeedbackDOM();
                break;
        }
    }

    // ==========================================
    //      UI 渲染模块 (仅顶层窗口)
    // ==========================================

    function getTooltip() {
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.id = 'qr-custom-tooltip';
            document.body.appendChild(tooltip);
        }
        return tooltip;
    }

    function renderTooltip(text, coords, isLink) {
        const tip = getTooltip();

        const contentColor = isLink ? '#4dabf7' : '#ffffff';
        const actionColor = '#4CAF50';

        tip.innerHTML = `
            <div style="color:${contentColor}; margin-bottom:6px;">${escapeHtml(text)}</div>
            <div style="color:${actionColor}; font-weight:bold; border-top:1px solid #444; padding-top:4px;">
                ${isLink ? '🔗 点击打开链接' : '📋 点击复制文本'}
            </div>
        `;

        tip.style.display = 'block';

        // --- 核心坐标修复逻辑 ---

        // 1. 获取顶层窗口的偏移量 (ScreenY - ClientY)
        // 如果有精确校准值(topWinOffset)则使用 否则使用估算值
        let offsetY, offsetX;

        if (topWinOffset) {
            // 精确模式:鼠标在顶层移动过 我们知道确切的 UI 高度
            offsetX = topWinOffset.x;
            offsetY = topWinOffset.y;
        } else {
            // 估算模式:鼠标只在 iframe 动过
            // 估算 UI 高度 = 窗口外高度 - 视口高度
            // 这通常能修正 95% 的误差 避免出现"几百像素"的偏差
            const winScreenX = window.screenX !== undefined ? window.screenX : window.screenLeft;
            const winScreenY = window.screenY !== undefined ? window.screenY : window.screenTop;

            // 假设左侧边框/滚动条宽度
            offsetX = winScreenX + (window.outerWidth - window.innerWidth);
            // 假设顶部 UI 高度 (地址栏等)
            offsetY = winScreenY + (window.outerHeight - window.innerHeight);
        }

        // 2. 计算 CSS 坐标
        // 元素屏幕坐标 - 视口起始屏幕坐标 = 元素视口内坐标
        let left = coords.absLeft - offsetX;
        let top = coords.absBottom - offsetY + 10; // 默认底部 + 10px

        // 3. 边界检测与翻转
        const tipRect = tip.getBoundingClientRect();
        const winHeight = window.innerHeight;
        const winWidth = window.innerWidth;

        // 垂直翻转:如果底部空间不足 移到上方
        if (top + tipRect.height > winHeight) {
            // 图片顶部屏幕坐标 - 视口起始Y - 提示框高度 - 间距
            top = (coords.absTop - offsetY) - tipRect.height - 10;
        }

        // 水平限制
        if (left + tipRect.width > winWidth) left = winWidth - tipRect.width - 10;
        if (left < 0) left = 10;

        tip.style.top = top + 'px';
        tip.style.left = left + 'px';
    }

    function hideTooltipDOM() {
        if (tooltip) tooltip.style.display = 'none';
    }

    function showFeedbackDOM() {
        const tip = getTooltip();
        if (tip.style.display === 'none') return;

        const originalHTML = tip.innerHTML;
        tip.innerHTML = `<div style="font-size:14px; text-align:center; color:#4dabf7; font-weight:bold;">✅ 已复制到剪贴板</div>`;

        setTimeout(() => {
            if (tip.style.display !== 'none') {
                tip.innerHTML = originalHTML;
            }
        }, 1000);
    }

    // ==========================================
    //      逻辑处理模块 (所有 Frame)
    // ==========================================

    function requestShowTooltip(text, imgElement) {
        if (currentImg === imgElement) return;
        currentImg = imgElement;

        const isLink = isUrl(text);
        const rect = imgElement.getBoundingClientRect();

        // 计算当前 Frame 的偏移 (鼠标屏幕坐标 - 鼠标 Client 坐标)
        // 如果没有鼠标数据 降级为 0
        const frameOffsetX = (lastMouseScreenX && lastMouseClientX) ? (lastMouseScreenX - lastMouseClientX) : 0;
        const frameOffsetY = (lastMouseScreenY && lastMouseClientY) ? (lastMouseScreenY - lastMouseClientY) : 0;

        // 计算图片的绝对屏幕坐标
        const coords = {
            absLeft: rect.left + frameOffsetX,
            absTop: rect.top + frameOffsetY,
            absBottom: rect.bottom + frameOffsetY
        };

        sendToTop('SHOW_TOOLTIP', { text, coords, isLink });
    }

    function requestHideTooltip() {
        currentImg = null;
        sendToTop('HIDE_TOOLTIP');
    }

    function requestFeedback() {
        sendToTop('SHOW_FEEDBACK');
    }

    function scanQR(img, force = false) {
        const src = img.src;
        if (!force && qrCache.has(src)) return;

        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        const tempImg = new Image();
        tempImg.crossOrigin = "Anonymous";
        tempImg.src = src;

        tempImg.onload = () => {
            processImage(tempImg, canvas, context, img, src, force);
        };

        tempImg.onerror = () => {
            // 方法 A 失败 (通常是跨域问题) 切换到方法 B
            // console.log('[QR] 标准加载失败 尝试 GM_xmlhttpRequest 强力加载:', src);
            scanQR_Fallback(img, src, force);
        };
    }

    // === 方法 B: 强力加载 (GM_xmlhttpRequest) ===
    function scanQR_Fallback(originalImg, src, force) {
        GM_xmlhttpRequest({
            method: "GET",
            url: src,
            responseType: "blob",
            onload: function(response) {
                if (response.status === 200) {
                    const blob = response.response;
                    const blobUrl = URL.createObjectURL(blob);
                    const tempImg = new Image();

                    tempImg.onload = () => {
                        const canvas = document.createElement('canvas');
                        const context = canvas.getContext('2d');
                        processImage(tempImg, canvas, context, originalImg, src, force);
                        URL.revokeObjectURL(blobUrl); // 释放内存
                    };

                    tempImg.onerror = () => {
                        qrCache.set(src, null);
                        URL.revokeObjectURL(blobUrl);
                    };

                    tempImg.src = blobUrl;
                } else {
                    qrCache.set(src, null);
                }
            },
            onerror: function() {
                qrCache.set(src, null);
            }
        });
    }

    // === 公共图像处理逻辑 ===
    function processImage(imageObj, canvas, context, originalImg, src, force) {
        canvas.width = imageObj.width;
        canvas.height = imageObj.height;
        context.drawImage(imageObj, 0, 0, imageObj.width, imageObj.height);

        try {
            const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
            const code = jsQR(imageData.data, imageData.width, imageData.height);

            if (code) {
                qrCache.set(src, code.data);
                applyQrSuccess(originalImg, code.data);
                if (force) console.log('[QR] 强制解析成功');
            } else {
                qrCache.set(src, null);
                if (force) console.log('[QR] 解析失败: 未发现二维码');
            }
        } catch (e) {
            // 如果这里报错 说明 Canvas 依然被污染 (极少见)
            qrCache.set(src, null);
        }
    }

    function applyQrSuccess(img, text) {
        img.dataset.hasQr = "true";
        img.classList.add('qr-detected-style');
        // 如果是强制解析(通常鼠标在图片上) 或者鼠标悬停中 则显示
        requestShowTooltip(text, img);
    }

    function isUrl(text) { return /^https?:\/\//i.test(text); }
    function escapeHtml(text) {
        return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
    }

    // ==========================================
    //      事件监听
    // ==========================================

    // 鼠标移动监听:用于实时校准坐标
    document.addEventListener('mousemove', (e) => {
        // 记录当前 Frame 的鼠标数据
        lastMouseScreenX = e.screenX;
        lastMouseScreenY = e.screenY;
        lastMouseClientX = e.clientX;
        lastMouseClientY = e.clientY;

        // 如果是顶层窗口 记录精确的视口偏移量 (这是最准确的校准源)
        if (isTop) {
            topWinOffset = {
                x: e.screenX - e.clientX,
                y: e.screenY - e.clientY
            };
        }
    }, true);

    document.addEventListener('mouseover', (e) => {
        const target = e.target;
        if (target.tagName !== 'IMG' || !target.complete || target.naturalWidth === 0) return;

        const src = target.src;
        if (!src) return;

        if (qrCache.has(src)) {
            const cachedData = qrCache.get(src);
            if (cachedData) {
                if (!target.dataset.hasQr) {
                    target.dataset.hasQr = "true";
                    target.classList.add('qr-detected-style');
                }
                requestShowTooltip(cachedData, target);
            }
            return;
        }

        const w = target.naturalWidth;
        const h = target.naturalHeight;
        if (Math.abs(w - h) > TOLERANCE || w < 30) {
            qrCache.set(src, null);
            return;
        }

        hoverTimer = setTimeout(() => {
            if (!qrCache.has(src)) {
                scanQR(target);
            }
        }, DELAY_MS);
    });

    document.addEventListener('mouseout', (e) => {
        if (e.target.tagName === 'IMG') {
            clearTimeout(hoverTimer);
            if (currentImg === e.target) {
                requestHideTooltip();
            }
        }
    });

    document.addEventListener('click', (e) => {
        const target = e.target;
        if (target.tagName === 'IMG' && target.dataset.hasQr === "true") {
            const src = target.src;
            const data = qrCache.get(src);

            if (data) {
                e.preventDefault();
                e.stopPropagation();

                if (isUrl(data)) {
                    GM_openInTab(data, { active: true, insert: true });
                } else {
                    GM_setClipboard(data);
                    requestFeedback();
                }
            }
        }
    }, true);

    // === 新增:双击强制解析 ===
    document.addEventListener('dblclick', (e) => {
        const target = e.target;
        if (target.tagName !== 'IMG' || !target.src) return;

        // 如果已经识别成功 双击不做处理(避免与单击冲突 或者重复触发)
        if (target.dataset.hasQr === "true") return;

        // 阻止默认双击行为(如选中图片)
        e.preventDefault();
        e.stopPropagation();

        // 强制解析:传入 true 忽略缓存和比例限制
        scanQR(target, true);
    }, true);

})();