鼠标悬停时自动在本地解析二维码
// ==UserScript== // @name 二维码自动解析 // @description 鼠标悬停时自动在本地解析二维码 // @namespace http://tampermonkey.net/ // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.min.js // @require https://unpkg.com/@zxing/library@latest/umd/index.min.js // @match *://*/* // @grant GM_setClipboard // @grant GM_openInTab // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-start // @version 2.6 // @author Gemini // @license GPLv3 // ==/UserScript== (function() { 'use strict'; // === 配置 === const DELAY_MS = 500; const TOLERANCE = 2; const CROP_TARGET_SIZE = 500; // 框选解析的最大尺寸 (超过此尺寸才缩小) // === ZXing 初始化 === let zxingReaderStrict = null; // 仅用于悬停 (只识二维码) let zxingReaderAll = null; // 用于强制解析 (识别所有) function getZXingReader(isForce) { if (!window.ZXing) return null; if (isForce) { // --- 模式 B: 强制解析 (全格式) --- if (!zxingReaderAll) { const hints = new Map(); // 不设置 POSSIBLE_FORMATS 默认识别所有格式 (EAN, Code128, QR等) hints.set(ZXing.DecodeHintType.TRY_HARDER, true); zxingReaderAll = new ZXing.BrowserMultiFormatReader(hints); } return zxingReaderAll; } else { // --- 模式 A: 悬停自动解析 (仅二维码) --- if (!zxingReaderStrict) { const hints = new Map(); // 显式限制只识别 QR Code 和 Data Matrix const formats = [ZXing.BarcodeFormat.QR_CODE, ZXing.BarcodeFormat.DATA_MATRIX]; hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, formats); // 悬停时也可以开启深度扫描 或者为了性能设为 false (这里建议开启以保证识别率) hints.set(ZXing.DecodeHintType.TRY_HARDER, true); zxingReaderStrict = new ZXing.BrowserMultiFormatReader(hints); } return zxingReaderStrict; } } // === 全局变量 === let hoverTimer = null; let tooltip = null; let currentTarget = null; // 坐标相关 let lastMouseScreenX = 0; let lastMouseScreenY = 0; let lastMouseClientX = 0; let lastMouseClientY = 0; let topWinOffset = null; // 组合键状态控制 let isRightClickHolding = false; let leftClickCount = 0; let interactionTarget = null; let suppressContextMenu = false; let suppressClick = false; // 框选相关 let isCropping = false; let isNoScaleCrop = false; let cropOverlay = null; let cropBox = null; let cropStart = { x: 0, y: 0 }; let cropTarget = null; // 会话缓存 const qrCache = new Map(); const canvasCache = new WeakMap(); 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; } /* 框选遮罩 */ #qr-crop-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.3); z-index: 2147483646; cursor: crosshair; display: none; } #qr-crop-box { position: absolute; border: 2px solid #4CAF50; background: rgba(76, 175, 80, 0.2); pointer-events: none; display: none; } `); // ========================================== // 通信模块 (跨域支持) // ========================================== 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, payload.method); 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, method) { const tip = getTooltip(); const contentColor = isLink ? '#4dabf7' : '#ffffff'; const actionColor = '#4CAF50'; const bracketColor = '#F6B64E'; const parenColor = '#B28BF7'; const isLoading = text.startsWith('⌛'); const isError = text.startsWith('❌'); // 构建标题 HTML let titleHtml = ''; if (method === '远程解析') { titleHtml = `<div style="margin-bottom:4px;"> <span style="color:${bracketColor}; font-weight:bold;">[远程解析]</span> </div>`; } else { // 本地解析 titleHtml = `<div style="margin-bottom:4px;"> <span style="color:${bracketColor}; font-weight:bold;">[本地解析]</span> <span style="color:${parenColor}; font-weight:bold;"> (${escapeHtml(method || '未知')})</span> </div>`; } 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 = ` ${titleHtml} <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'; // --- 坐标计算 --- let offsetY, offsetX; if (topWinOffset) { offsetX = topWinOffset.x; offsetY = topWinOffset.y; } else { const winScreenX = window.screenX !== undefined ? window.screenX : window.screenLeft; const winScreenY = window.screenY !== undefined ? window.screenY : window.screenTop; offsetX = winScreenX + (window.outerWidth - window.innerWidth); offsetY = winScreenY + (window.outerHeight - window.innerHeight); } let left = coords.absLeft - offsetX; let top = coords.absBottom - offsetY + 10; const tipRect = tip.getBoundingClientRect(); const winHeight = window.innerHeight; const winWidth = window.innerWidth; if (top + tipRect.height > winHeight) { 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, method = "JSQR") { if (currentTarget !== element) currentTarget = element; const isLink = isUrl(text); const rect = element.getBoundingClientRect(); 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, method }); } function requestHideTooltip() { currentTarget = null; sendToTop('HIDE_TOOLTIP'); } function requestFeedback() { sendToTop('SHOW_FEEDBACK'); } // ========================================== // 框选逻辑 // ========================================== function startCropMode(target, noScale = false) { if (isCropping) return; isCropping = true; isNoScaleCrop = noScale; cropTarget = target; if (!cropOverlay) { cropOverlay = document.createElement('div'); cropOverlay.id = 'qr-crop-overlay'; cropBox = document.createElement('div'); cropBox.id = 'qr-crop-box'; cropOverlay.appendChild(cropBox); document.body.appendChild(cropOverlay); // 辅助函数 const clamp = (val, min, max) => Math.min(Math.max(val, min), max); // 右键取消 cropOverlay.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); endCropMode(); // 使用全局 cropTarget if (cropTarget) requestShowTooltip("❌ 已取消框选", cropTarget); return false; }); // 鼠标按下开始框选 cropOverlay.addEventListener('mousedown', (e) => { if (e.button === 2 || !cropTarget) return; // 关键修复 2: 在点击瞬间动态获取当前目标的 Rect // 确保获取的是当前 cropTarget 的位置 而不是第一次初始化时的位置 const imgRect = cropTarget.getBoundingClientRect(); // 限制起点坐标 const startX = clamp(e.clientX, imgRect.left, imgRect.right); const startY = clamp(e.clientY, imgRect.top, imgRect.bottom); cropStart = { x: startX, y: startY }; cropBox.style.left = startX + 'px'; cropBox.style.top = startY + 'px'; cropBox.style.width = '0px'; cropBox.style.height = '0px'; cropBox.style.display = 'block'; const onMove = (ev) => { // 限制终点坐标 const curX = clamp(ev.clientX, imgRect.left, imgRect.right); const curY = clamp(ev.clientY, imgRect.top, imgRect.bottom); const width = Math.abs(curX - cropStart.x); const height = Math.abs(curY - cropStart.y); const left = Math.min(curX, cropStart.x); const top = Math.min(curY, cropStart.y); cropBox.style.width = width + 'px'; cropBox.style.height = height + 'px'; cropBox.style.left = left + 'px'; cropBox.style.top = top + 'px'; }; const onUp = (ev) => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); if (ev.button !== 0 || !isCropping) return; const rect = cropBox.getBoundingClientRect(); endCropMode(); if (rect.width < 5 || rect.height < 5) return; // 关键修复 3: 将当前的 cropTarget 传递给处理函数 processCropScan(cropTarget, rect); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); } cropOverlay.style.display = 'block'; const tipText = noScale ? "⌛ 原图框选" : "⌛ 缩放框选"; requestShowTooltip(tipText, target); } function endCropMode() { isCropping = false; if (cropOverlay) cropOverlay.style.display = 'none'; if (cropBox) cropBox.style.display = 'none'; } function processCropScan(target, selectionRect) { const targetRect = target.getBoundingClientRect(); const selX = selectionRect.left; const selY = selectionRect.top; const selW = selectionRect.width; const selH = selectionRect.height; const imgX = targetRect.left; const imgY = targetRect.top; const relX = selX - imgX; const relY = selY - imgY; const cropRect = { x: relX, y: relY, w: selW, h: selH, noScale: isNoScaleCrop }; scanElement(target, true, cropRect); } // === 统一入口 === function scanElement(target, force = false, cropRect = null) { if (target.tagName === 'IMG') { scanImage(target, force, cropRect); } else if (target.tagName === 'CANVAS') { scanCanvas(target, force, cropRect); } } // === 远程解析 === 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) { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, "text/html"); 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, { text: resultText, method: "远程解析" }); applyQrSuccess(target, resultText, "远程解析"); } else { requestShowTooltip("❌ 远程解析失败", target); } } else { requestShowTooltip("❌ 远程服务器响应错误: " + response.status, target); } }, onerror: function() { requestShowTooltip("❌ 网络请求失败", target); } }); } // ========================================== // 图像获取与预处理 // ========================================== function scanImage(img, force, cropRect) { const src = img.src; if (!src) return; if (!force && !cropRect && qrCache.has(src)) return; let displayWidth = img.width || img.clientWidth || 0; let displayHeight = img.height || img.clientHeight || 0; 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', displayWidth, displayHeight, cropRect); tempImg.onerror = () => scanImage_Fallback(img, src, force, displayWidth, displayHeight, cropRect); } function scanImage_Fallback(originalImg, src, force, w, h, cropRect) { 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', w, h, cropRect); URL.revokeObjectURL(blobUrl); }; tempImg.onerror = () => { if (!cropRect) qrCache.set(src, null); URL.revokeObjectURL(blobUrl); }; tempImg.src = blobUrl; } else { if (!cropRect) qrCache.set(src, null); } }, onerror: () => { if (!cropRect) qrCache.set(src, null); } }); } function scanCanvas(canvasEl, force, cropRect) { if (!force && !cropRect && canvasCache.has(canvasEl)) return; try { let context = canvasEl.getContext('2d'); if (context) { try { const imageData = context.getImageData(0, 0, canvasEl.width, canvasEl.height); // 1. 确定源尺寸 const sourceW = canvasEl.width; const sourceH = canvasEl.height; // 2. 计算裁剪 let drawX = 0, drawY = 0, drawW = sourceW, drawH = sourceH; if (cropRect) { const clientW = canvasEl.clientWidth || sourceW; const clientH = canvasEl.clientHeight || sourceH; const scaleX = sourceW / clientW; const scaleY = sourceH / clientH; drawX = cropRect.x * scaleX; drawY = cropRect.y * scaleY; drawW = cropRect.w * scaleX; drawH = cropRect.h * scaleY; } // 3. 计算缩放 (仅缩小 不放大) let targetW = drawW; let targetH = drawH; if (cropRect) { const maxDim = Math.max(drawW, drawH); // 只有当尺寸超过目标尺寸时才缩放 if (maxDim > CROP_TARGET_SIZE) { const scale = CROP_TARGET_SIZE / maxDim; targetW = drawW * scale; targetH = drawH * scale; } } // 4. 绘制到新 Canvas (加白边) const padding = 50; const finalCanvas = document.createElement('canvas'); finalCanvas.width = targetW + (padding * 2); finalCanvas.height = targetH + (padding * 2); const finalCtx = finalCanvas.getContext('2d'); finalCtx.fillStyle = '#FFFFFF'; finalCtx.fillRect(0, 0, finalCanvas.width, finalCanvas.height); // 绘制并缩放 finalCtx.drawImage(canvasEl, drawX, drawY, drawW, drawH, padding, padding, targetW, targetH); runScanPipeline(finalCanvas, finalCtx, canvasEl, force, 'CANVAS', canvasEl, !!cropRect); } catch (e) { canvasCache.set(canvasEl, null); } } else { 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', canvasEl.width, canvasEl.height, cropRect); }; tempImg.src = dataUrl; } } catch (e) { canvasCache.set(canvasEl, null); } } // === 高质量缩放辅助函数 (模拟 Lanczos 效果) === function smartDownscale(imageObj, ctx, sourceX, sourceY, sourceW, sourceH, targetX, targetY, targetW, targetH) { // 1. 开启浏览器最高质量插值 ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; // 2. 如果缩放比例小于 2 倍 直接绘制 (分步缩放收益不大) if (sourceW <= targetW * 2 && sourceH <= targetH * 2) { ctx.drawImage(imageObj, sourceX, sourceY, sourceW, sourceH, targetX, targetY, targetW, targetH); return; } // 3. 分步缩放逻辑 // 创建临时 Canvas 进行中间态处理 let tempCanvas = document.createElement('canvas'); let tempCtx = tempCanvas.getContext('2d'); let curW = sourceW; let curH = sourceH; tempCanvas.width = curW; tempCanvas.height = curH; // 第一步:裁剪原图到临时 Canvas tempCtx.drawImage(imageObj, sourceX, sourceY, sourceW, sourceH, 0, 0, curW, curH); // 循环减半缩放 直到接近目标尺寸 while (curW > targetW * 2) { const newW = Math.floor(curW * 0.5); const newH = Math.floor(curH * 0.5); // 创建更小的临时 Canvas let nextCanvas = document.createElement('canvas'); nextCanvas.width = newW; nextCanvas.height = newH; let nextCtx = nextCanvas.getContext('2d'); // 绘制缩小版 nextCtx.drawImage(tempCanvas, 0, 0, curW, curH, 0, 0, newW, newH); // 更新引用 curW = newW; curH = newH; tempCanvas = nextCanvas; // 丢弃旧的大 Canvas } // 4. 最后一步:绘制到目标 Canvas ctx.drawImage(tempCanvas, 0, 0, curW, curH, targetX, targetY, targetW, targetH); } function processImage(imageObj, canvas, context, targetEl, cacheKey, force, type, displayWidth, displayHeight, cropRect) { // 1. 获取原始尺寸 let naturalW = imageObj.naturalWidth; let naturalH = imageObj.naturalHeight; // SVG 检测 const isSVG = /\.svg($|\?|#)/i.test(imageObj.src) || /^data:image\/svg/i.test(imageObj.src); const isUnknownSize = !naturalW || naturalW === 0; // 标记:是否必须使用 5 参数模式 (SVG 或 无尺寸图片) const forceSimpleMode = isSVG || isUnknownSize; // 如果没有原始尺寸(通常是某些 SVG) 才使用显示尺寸兜底 // 如果 SVG 有原始尺寸(如 width="1000") 则保留原始尺寸以获得更高清晰度 if (isUnknownSize) { naturalW = displayWidth || 300; naturalH = displayHeight || 300; } // 2. 计算目标尺寸 let targetW = naturalW; let targetH = naturalH; // 仅在框选模式下计算裁剪尺寸 if (cropRect && !forceSimpleMode) { const scaleX = naturalW / displayWidth; const scaleY = naturalH / displayHeight; targetW = cropRect.w * scaleX; targetH = cropRect.h * scaleY; } // === 关键修改:缩放限制逻辑 === // 只有在【框选模式】下才执行缩小 (为了性能和聚焦) // 【全图模式】下始终保持 1:1 原始分辨率 (为了最高识别率) if (cropRect) { // 如果 cropRect.noScale 为 true 则跳过缩小逻辑 if (!cropRect.noScale) { const maxDim = Math.max(targetW, targetH); if (maxDim > CROP_TARGET_SIZE) { const scale = CROP_TARGET_SIZE / maxDim; targetW *= scale; targetH *= scale; } } } const padding = 50; canvas.width = targetW + (padding * 2); canvas.height = targetH + (padding * 2); // 3. 绘制背景 context.fillStyle = '#FFFFFF'; context.fillRect(0, 0, canvas.width, canvas.height); // 4. 绘制图像 if (cropRect && !forceSimpleMode) { // 【模式 A:位图裁剪】(仅框选且非SVG) const scaleX = naturalW / displayWidth; const scaleY = naturalH / displayHeight; let sourceX = cropRect.x * scaleX; let sourceY = cropRect.y * scaleY; let sourceW = cropRect.w * scaleX; let sourceH = cropRect.h * scaleY; // 边界保护 if (sourceX < 0) sourceX = 0; if (sourceY < 0) sourceY = 0; if (sourceX + sourceW > naturalW) sourceW = naturalW - sourceX; if (sourceY + sourceH > naturalH) sourceH = naturalH - sourceY; // 框选模式下 targetW 已经被限制在 500px 以内 smartDownscale 会自动处理缩放 smartDownscale(imageObj, context, sourceX, sourceY, sourceW, sourceH, padding, padding, targetW, targetH); } else { // 【模式 B:全图模式】(SVG 或 全图位图) if (forceSimpleMode) { // SVG: 浏览器原生绘制 (矢量无损) context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; context.drawImage(imageObj, padding, padding, targetW, targetH); } else { // 位图全图: // 因为移除了尺寸限制 targetW 等于 naturalW // smartDownscale 内部检测到源尺寸和目标尺寸一致时 会直接绘制 不会产生性能损耗 smartDownscale(imageObj, context, 0, 0, naturalW, naturalH, padding, padding, targetW, targetH); } } runScanPipeline(canvas, context, targetEl, force, type, cacheKey, !!cropRect); } // ========================================== // 核心扫描管道 (JSQR + ZXing) // ========================================== async function runScanPipeline(canvas, context, targetEl, force, type, cacheKey, isCrop) { if (force) requestShowTooltip("⌛ 正在进行强制解析...", targetEl); let result = null; const imageData = context.getImageData(0, 0, canvas.width, canvas.height); const suffix = isCrop ? " 框选" : ""; // --- 阶段 1: 标准解析 --- // 1.1 JSQR 标准 result = jsQR(imageData.data, imageData.width, imageData.height); if (result) { handleSuccess(result.data, "JSQR" + suffix, type, cacheKey, targetEl); return; } // 1.2 ZXing 标准 result = await tryZXing(canvas, force); if (result) { handleSuccess(result, "ZXing" + suffix, type, cacheKey, targetEl); return; } if (!force) { handleFail(type, cacheKey, targetEl, false); return; } // --- 阶段 2: 增强解析 (仅强制模式) --- // 反色数据准备 const invertedData = new Uint8ClampedArray(imageData.data); for (let i = 0; i < invertedData.length; i += 4) { invertedData[i] = 255 - invertedData[i]; invertedData[i + 1] = 255 - invertedData[i + 1]; invertedData[i + 2] = 255 - invertedData[i + 2]; invertedData[i + 3] = 255; } // 2.1 JSQR 反色 result = jsQR(invertedData, imageData.width, imageData.height); if (result) { handleSuccess(result.data, "JSQR 反色" + suffix, type, cacheKey, targetEl); return; } // 2.2 ZXing 反色 const invertedImageData = new ImageData(invertedData, canvas.width, canvas.height); context.putImageData(invertedImageData, 0, 0); result = await tryZXing(canvas, force); if (result) { handleSuccess(result, "ZXing 反色" + suffix, type, cacheKey, targetEl); return; } // 二值化数据准备 const binarizedData = new Uint8ClampedArray(imageData.data); const len = binarizedData.length; let totalLum = 0; for (let i = 0; i < len; i += 4) { totalLum += 0.299 * binarizedData[i] + 0.587 * binarizedData[i+1] + 0.114 * binarizedData[i+2]; } const avgLum = totalLum / (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 > avgLum ? 255 : 0; binarizedData[i] = val; binarizedData[i+1] = val; binarizedData[i+2] = val; binarizedData[i+3] = 255; } // 2.3 JSQR 二值化 result = jsQR(binarizedData, imageData.width, imageData.height); if (result) { handleSuccess(result.data, "JSQR 二值化" + suffix, type, cacheKey, targetEl); return; } // 2.4 ZXing 二值化 const binarizedImageData = new ImageData(binarizedData, canvas.width, canvas.height); context.putImageData(binarizedImageData, 0, 0); result = await tryZXing(canvas, force); if (result) { handleSuccess(result, "ZXing 二值化" + suffix, type, cacheKey, targetEl); return; } handleFail(type, cacheKey, targetEl, true); } function tryZXing(canvas, isForce) { return new Promise((resolve) => { if (typeof ZXing === 'undefined') { resolve(null); return; } const dataUrl = canvas.toDataURL('image/png'); const img = new Image(); img.onload = () => { // 关键修改:将 isForce 传入获取对应的 Reader const reader = getZXingReader(isForce); if (!reader) { resolve(null); return; } reader.decodeFromImageElement(img) .then(res => resolve(res.text)) .catch(() => resolve(null)); }; img.onerror = () => resolve(null); img.src = dataUrl; }); } function handleSuccess(text, method, type, cacheKey, targetEl) { const cacheObj = { text: text, method: method }; if (type === 'IMG') qrCache.set(cacheKey, cacheObj); else canvasCache.set(targetEl, cacheObj); applyQrSuccess(targetEl, text, method); } function handleFail(type, cacheKey, targetEl, isForce) { if (!isForce) { if (type === 'IMG') qrCache.set(cacheKey, null); else canvasCache.set(targetEl, null); } if (isForce) { requestShowTooltip("❌ 强制解析失败", targetEl); } } // ========================================== // 公共辅助函数 // ========================================== function applyQrSuccess(el, text, method) { if (!method.includes("框选")) { el.dataset.hasQr = "true"; el.classList.add('qr-detected-style'); } requestShowTooltip(text, el, method); } function isUrl(text) { if (!text) return false; // ^ : 开始 // \s*: 允许开头有空格 // https?:\/\/: 协议 // [^\s]+: 链接主体不能包含空格 // \s*: 允许结尾有空格 // $ : 结束 return /^\s*https?:\/\/[^\s]+\s*$/i.test(text); } function escapeHtml(text) { if (!text) return ""; return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); } // ========================================== // 事件监听 // ========================================== document.addEventListener('mousemove', (e) => { 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) => { if (isCropping) return; const target = e.target; 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 cacheData = qrCache.get(target.src); if (cacheData) { if (!target.dataset.hasQr) applyQrSuccess(target, cacheData.text, cacheData.method); else requestShowTooltip(cacheData.text, target, cacheData.method); } return; } if (isCanvas && canvasCache.has(target)) { const cacheData = canvasCache.get(target); if (cacheData) { if (!target.dataset.hasQr) applyQrSuccess(target, cacheData.text, cacheData.method); else requestShowTooltip(cacheData.text, target, cacheData.method); } return; } 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 (isCropping) return; if (isImg && qrCache.has(target.src)) return; if (isCanvas && canvasCache.has(target)) return; scanElement(target, false); }, DELAY_MS); }); document.addEventListener('mouseout', (e) => { const t = e.target; if (t.tagName === 'IMG' || t.tagName === 'CANVAS') { clearTimeout(hoverTimer); if (currentTarget === t && !isCropping) { requestHideTooltip(); } } }); // === 交互逻辑 === document.addEventListener('mousedown', (e) => { if (isCropping) return; if (e.button === 2) { isRightClickHolding = true; leftClickCount = 0; interactionTarget = e.target; suppressContextMenu = false; } 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); document.addEventListener('mouseup', (e) => { if (isCropping) return; if (e.button === 2) { isRightClickHolding = false; if (leftClickCount > 0 && interactionTarget) { // 1次点击 -> 强制本地解析 (全策略) if (leftClickCount === 1) { scanElement(interactionTarget, true); } // 2次点击 -> 远程解析 else if (leftClickCount === 2) { scanExternal(interactionTarget); } // 3次点击 -> 普通框选 (会缩小到 500px) else if (leftClickCount === 3) { startCropMode(interactionTarget, false); } // 4次点击 -> 原图框选 (不缩小) else if (leftClickCount === 4) { startCropMode(interactionTarget, true); } } interactionTarget = null; leftClickCount = 0; } }, true); document.addEventListener('contextmenu', (e) => { if (suppressContextMenu) { e.preventDefault(); e.stopPropagation(); suppressContextMenu = false; } }, 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') { const c = qrCache.get(target.src); if (c) data = c.text; } else { const c = canvasCache.get(target); if (c) data = c.text; } if (data) { e.preventDefault(); e.stopPropagation(); if (isUrl(data)) { GM_openInTab(data, { active: true, insert: true }); } else { GM_setClipboard(data); requestFeedback(); } } } }, true); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isCropping) { endCropMode(); requestShowTooltip("❌ 已取消框选", currentTarget || document.body); } }); })();