鼠标悬停时自动在本地解析二维码
// ==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-start // @version 1.8 // @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; // 组合键状态控制 let isRightClickHolding = false; let leftClickCount = 0; // 记录右键按住期间左键点击的次数 let interactionTarget = null; // 记录组合键开始时的目标元素 let suppressContextMenu = false; // 拦截右键菜单 let suppressClick = false; // 拦截左键点击 // 会话缓存 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'; // 检查是否是加载状态或错误信息 const isLoading = text.startsWith('⌛'); const isError = text.startsWith('❌'); let htmlContent = ''; if (isLoading) { htmlContent = `<div style="color:#FFD700; font-weight:bold;">${escapeHtml(text)}</div>`; } else if (isError) { htmlContent = `<div style="color:#FF5252; font-weight:bold;">${escapeHtml(text)}</div>`; } else { htmlContent = ` <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.innerHTML = htmlContent; 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) { // 允许在加载状态下刷新 Tooltip 内容 if (currentTarget !== element) 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); } } // === 外部解析 === function scanExternal(target) { if (target.tagName !== 'IMG' || !target.src || !/^http/.test(target.src)) { requestShowTooltip("❌ 外部解析仅支持 http/https 图片链接", target); return; } const src = target.src; requestShowTooltip("⌛ 正在连接远程服务器解析...", target); GM_xmlhttpRequest({ method: "GET", url: "https://zxing.org/w/decode?u=" + encodeURIComponent(src), onload: function(response) { if (response.status === 200) { // 解析 HTML 提取结果 const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, "text/html"); // 查找结构: <td>Parsed Result</td><td><pre>RESULT</pre></td> // 使用 XPath 或 QuerySelector 查找 const tds = doc.querySelectorAll('td'); let resultText = null; for (let i = 0; i < tds.length; i++) { if (tds[i].textContent.trim() === "Parsed Result") { const nextTd = tds[i].nextElementSibling; if (nextTd) { const pre = nextTd.querySelector('pre'); if (pre) { resultText = pre.textContent; break; } } } } if (resultText) { qrCache.set(src, resultText); applyQrSuccess(target, resultText); } else { requestShowTooltip("❌ 外部解析失败", target); } } else { requestShowTooltip("❌ 远程服务器响应错误: " + response.status, target); } }, onerror: function() { requestShowTooltip("❌ 网络请求失败", target); } }); } // === 分支 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); // 直接调用处理逻辑 复用反转代码 processImageData(imageData, canvasEl, force, 'CANVAS', canvasEl); } catch (e) { // 如果 Canvas 被污染 (Tainted) getImageData 会报错 // 这种情况下通常无法读取 除非用特殊手段 但这里只能标记失败 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); processImageData(imageData, targetEl, force, type, cacheKey); } catch (e) { if (type === 'IMG') qrCache.set(cacheKey, null); else canvasCache.set(targetEl, null); if (force) { requestShowTooltip("❌ 解析出错 (可能是跨域限制)", targetEl); } } } // === 核心处理逻辑 (三级解析:原图 -> [强制模式下: 反转 -> 二值化]) === function processImageData(imageData, targetEl, force, type, cacheKey) { // 1. 尝试正常解析 (所有模式都执行) let code = jsQR(imageData.data, imageData.width, imageData.height); // 修改点:仅在强制解析模式下 (force=true) 且正常解析失败时 才尝试高级策略 if (!code && force) { // 2. 尝试颜色反转解析 const invertedData = new Uint8ClampedArray(imageData.data); for (let i = 0; i < invertedData.length; i += 4) { invertedData[i] = 255 - invertedData[i]; // R invertedData[i + 1] = 255 - invertedData[i + 1]; // G invertedData[i + 2] = 255 - invertedData[i + 2]; // B } code = jsQR(invertedData, imageData.width, imageData.height); // 3. 如果还失败 尝试二值化 (Binarization) if (!code) { const binarizedData = new Uint8ClampedArray(imageData.data); const len = binarizedData.length; // 计算平均亮度作为阈值 let totalLuminance = 0; for (let i = 0; i < len; i += 4) { const lum = 0.299 * binarizedData[i] + 0.587 * binarizedData[i+1] + 0.114 * binarizedData[i+2]; totalLuminance += lum; } const avgLuminance = totalLuminance / (len / 4); // 应用二值化 for (let i = 0; i < len; i += 4) { const lum = 0.299 * binarizedData[i] + 0.587 * binarizedData[i+1] + 0.114 * binarizedData[i+2]; const val = lum > avgLuminance ? 255 : 0; binarizedData[i] = val; binarizedData[i+1] = val; binarizedData[i+2] = val; } code = jsQR(binarizedData, imageData.width, imageData.height); } } handleScanResult(code, targetEl, force, type, cacheKey); } 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); } else { if (type === 'IMG') qrCache.set(imgCacheKey, null); else canvasCache.set(targetEl, null); if (force) { requestShowTooltip("❌ 强制解析失败", targetEl); } } } 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); } // ========================================== // 事件监听 // ========================================== // 鼠标移动监听:用于实时校准坐标 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(); } } }); // === 交互逻辑:右键+左键计数 === // 1. 监听鼠标按下 document.addEventListener('mousedown', (e) => { // 右键按下 (button 2) if (e.button === 2) { isRightClickHolding = true; leftClickCount = 0; // 重置计数 interactionTarget = e.target; // 记录目标 suppressContextMenu = false; } // 左键按下 (button 0) else if (e.button === 0) { // 如果右键按住中 -> 计数 if (isRightClickHolding) { if (interactionTarget && (interactionTarget.tagName === 'IMG' || interactionTarget.tagName === 'CANVAS')) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); leftClickCount++; suppressContextMenu = true; // 屏蔽右键菜单 suppressClick = true; // 屏蔽左键点击 } } } }, true); // 2. 监听鼠标松开 (触发逻辑的核心) document.addEventListener('mouseup', (e) => { // 右键松开 (button 2) if (e.button === 2) { isRightClickHolding = false; // 如果在按住期间点击了左键 if (leftClickCount > 0 && interactionTarget) { // 1次点击 -> 强制本地解析 if (leftClickCount === 1) { scanElement(interactionTarget, true); } // 2次及以上点击 -> 外部解析 else if (leftClickCount >= 2) { scanExternal(interactionTarget); } } // 重置目标 interactionTarget = null; leftClickCount = 0; } }, true); // 3. 屏蔽右键菜单 document.addEventListener('contextmenu', (e) => { if (suppressContextMenu) { e.preventDefault(); e.stopPropagation(); suppressContextMenu = false; } }, true); // 4. 屏蔽点击事件 (关键修复) // 必须使用捕获阶段 (true) 确保在任何其他点击逻辑之前拦截 document.addEventListener('click', (e) => { // 如果处于拦截状态 直接销毁事件 if (suppressClick) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); suppressClick = false; // 重置状态 return; } // 下面是正常的点击处理逻辑 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); })();