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.3
// @author       Gemini
// @license      GPLv3
// ==/UserScript==

(function() {
    'use strict';

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

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

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

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

    // 会话缓存
    const qrCache = new Map(); // 用于图片 (Key: src string)
    const canvasCache = new WeakMap(); // 用于Canvas (Key: DOM Element)

    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, element) {
        if (currentTarget === element) return;
        currentTarget = element;

        const isLink = isUrl(text);
        const rect = element.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() {
        currentTarget = null;
        sendToTop('HIDE_TOOLTIP');
    }

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

    // === 统一入口:扫描元素 ===
    function scanElement(target, force = false) {
        if (target.tagName === 'IMG') {
            scanImage(target, force);
        } else if (target.tagName === 'CANVAS') {
            scanCanvas(target, force);
        }
    }

    // === 分支 A: 扫描图片 ===
    function scanImage(img, force) {
        const src = img.src;
        if (!src) return;
        if (!force && qrCache.has(src)) return;

        // 1. 标准加载
        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, 'IMG');
        tempImg.onerror = () => scanImage_Fallback(img, src, force);
    }

    // === 方法 B: 强力加载 (GM_xmlhttpRequest) ===
    function scanImage_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, 'IMG');
                        URL.revokeObjectURL(blobUrl);
                    };
                    tempImg.onerror = () => {
                        qrCache.set(src, null);
                        URL.revokeObjectURL(blobUrl);
                    };
                    tempImg.src = blobUrl;
                } else {
                    qrCache.set(src, null);
                }
            },
            onerror: () => qrCache.set(src, null)
        });
    }

    // === 分支 B: 扫描 Canvas ===
    function scanCanvas(canvasEl, force) {
        if (!force && canvasCache.has(canvasEl)) return;

        try {
            // 尝试直接获取 2D 上下文数据 (最高效)
            // 注意:如果 Canvas 是 WebGL 的 getContext('2d') 可能会返回 null
            let context = canvasEl.getContext('2d');

            if (context) {
                // 2D Canvas 路径
                try {
                    const imageData = context.getImageData(0, 0, canvasEl.width, canvasEl.height);
                    const code = jsQR(imageData.data, imageData.width, imageData.height);
                    handleScanResult(code, canvasEl, force, 'CANVAS');
                } catch (e) {
                    // 如果 Canvas 被污染 (Tainted) getImageData 会报错
                    // 这种情况下通常无法读取 除非用特殊手段 但这里只能标记失败
                    console.log('[QR] Canvas Tainted:', e);
                    canvasCache.set(canvasEl, null);
                }
            } else {
                // WebGL 或其他 Context 路径 -> 尝试转为 DataURL
                const dataUrl = canvasEl.toDataURL();
                const tempImg = new Image();
                tempImg.onload = () => {
                    const tempCanvas = document.createElement('canvas');
                    const tempCtx = tempCanvas.getContext('2d');
                    processImage(tempImg, tempCanvas, tempCtx, canvasEl, null, force, 'CANVAS');
                };
                tempImg.src = dataUrl;
            }
        } catch (e) {
            // toDataURL 也可能因为 Tainted 报错
            canvasCache.set(canvasEl, null);
        }
    }

    // === 公共处理逻辑 ===
    function processImage(imageObj, canvas, context, targetEl, cacheKey, force, type) {
        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);
            handleScanResult(code, targetEl, force, type, cacheKey);
        } catch (e) {
            if (type === 'IMG') qrCache.set(cacheKey, null);
            else canvasCache.set(targetEl, null);
        }
    }

    function handleScanResult(code, targetEl, force, type, imgCacheKey) {
        if (code) {
            if (type === 'IMG') qrCache.set(imgCacheKey, code.data);
            else canvasCache.set(targetEl, code.data);

            applyQrSuccess(targetEl, code.data);
            if (force) console.log(`[QR] ${type} 强制解析成功`);
        } else {
            if (type === 'IMG') qrCache.set(imgCacheKey, null);
            else canvasCache.set(targetEl, null);

            if (force) console.log(`[QR] ${type} 解析失败`);
        }
    }

    function applyQrSuccess(el, text) {
        el.dataset.hasQr = "true";
        el.classList.add('qr-detected-style');
        requestShowTooltip(text, el);
    }

    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;
        // 支持 IMG 和 CANVAS
        const isImg = target.tagName === 'IMG';
        const isCanvas = target.tagName === 'CANVAS';

        if (!isImg && !isCanvas) return;
        if (isImg && (!target.complete || target.naturalWidth === 0)) return;

        // 检查缓存
        if (isImg && target.src && qrCache.has(target.src)) {
            const data = qrCache.get(target.src);
            if (data) {
                if (!target.dataset.hasQr) applyQrSuccess(target, data);
                else requestShowTooltip(data, target);
            }
            return;
        }
        if (isCanvas && canvasCache.has(target)) {
            const data = canvasCache.get(target);
            if (data) {
                if (!target.dataset.hasQr) applyQrSuccess(target, data);
                else requestShowTooltip(data, target);
            }
            return;
        }

        // 检查比例 (Canvas 使用 width/height 属性或 clientWidth/Height)
        let w, h;
        if (isImg) {
            w = target.naturalWidth;
            h = target.naturalHeight;
        } else {
            w = target.width || target.clientWidth;
            h = target.height || target.clientHeight;
        }

        if (Math.abs(w - h) > TOLERANCE || w < 30) {
            if (isImg && target.src) qrCache.set(target.src, null);
            else if (isCanvas) canvasCache.set(target, null);
            return;
        }

        hoverTimer = setTimeout(() => {
            // 再次检查缓存防止重复
            if (isImg && qrCache.has(target.src)) return;
            if (isCanvas && canvasCache.has(target)) return;
            scanElement(target);
        }, DELAY_MS);
    });

    document.addEventListener('mouseout', (e) => {
        const t = e.target;
        if (t.tagName === 'IMG' || t.tagName === 'CANVAS') {
            clearTimeout(hoverTimer);
            if (currentTarget === t) {
                requestHideTooltip();
            }
        }
    });

    document.addEventListener('click', (e) => {
        const target = e.target;
        if ((target.tagName === 'IMG' || target.tagName === 'CANVAS') && target.dataset.hasQr === "true") {
            // 获取数据
            let data = null;
            if (target.tagName === 'IMG') data = qrCache.get(target.src);
            else data = canvasCache.get(target);

            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.tagName !== 'CANVAS') return;

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

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

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

})();