// ==UserScript== // @name 悬景 · HoverVista|鼠标悬停图片自动放大预览 // @namespace https://github.com/YDGG123 // @version 5.7.2 // @description 网页图片鼠标悬停自动放大工具:智能自适应、高清图后台升级、滚轮边界控制 // @author 益达哥哥 // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @run-at document-end // @noframes // @license MIT // @homepageURL https://github.com/YDGG123/hover-image-zoom // @supportURL https://github.com/YDGG123/hover-image-zoom/issues // ==/UserScript== (function() { 'use strict'; const SCRIPT_VERSION = "5.7.2"; // ================ // 存储读写封装 // ================ const storage = { get(key, defaultValue) { return GM_getValue(key, defaultValue); }, set(key, value) { return GM_setValue(key, value); } }; const storageGet = (key, defaultValue) => storage.get(key, defaultValue); const storageSet = (key, value) => storage.set(key, value); function getDomain() { try { return new URL(window.location.href).hostname; } catch (e) { return window.location.hostname || 'unknown'; } } function debounce(fn, wait) { let t; return function(...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), wait); }; } // 仅前沿节流(无尾随重放:每次执行携带自己那一刻的真实事件,无过期数据) function throttleLeading(fn, wait) { let lastExec = 0; return function(...a) { const now = Date.now(); if (now - lastExec >= wait) { lastExec = now; fn.apply(this, a); } }; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function isHomepage() { const p = window.location.pathname; return p === '/' || p === '/index.html' || p === '/index.php' || p === ''; } const FADE_MS = 300; const HEARTBEAT_MS = 150; // 坐标权威源:只由 mousemove 写入。 // 停稳裁决器与后续校验(心跳/TIMER_FIRE)使用 const lastMouse = { x: -1, y: -1, t: 0 }; // ★ 悬停等待指示器:停稳后继续等待 300ms 才出现,使用轻量的“三点聚焦”动画(增强可见度)。 // 不拦截鼠标事件;进入 SHOWING/ACTIVE/FADING、切图或取消等待时立即隐藏。 let hoverWaitIndicator = null; let hoverWaitIndicatorTimer = null; function ensureHoverWaitIndicator() { if (hoverWaitIndicator && hoverWaitIndicator.isConnected) return hoverWaitIndicator; const el = document.createElement('div'); el.id = 'hoverWaitIndicator'; el.setAttribute('aria-hidden', 'true'); el.innerHTML = ''; document.documentElement.appendChild(el); hoverWaitIndicator = el; return el; } function positionHoverWaitIndicator(x, y) { const el = ensureHoverWaitIndicator(); el.style.left = Math.round(x) + 'px'; el.style.top = Math.round(y) + 'px'; } function showHoverWaitIndicator(x, y, duration) { const el = ensureHoverWaitIndicator(); positionHoverWaitIndicator(x, y + 18); el.style.setProperty('--hover-wait-duration', Math.max(80, duration || config.delay || 800) + 'ms'); el.classList.add('show'); } function hideHoverWaitIndicator() { if (hoverWaitIndicatorTimer) { clearTimeout(hoverWaitIndicatorTimer); hoverWaitIndicatorTimer = null; } if (hoverWaitIndicator) hoverWaitIndicator.classList.remove('show'); } function scheduleHoverWaitIndicator(img) { hideHoverWaitIndicator(); if (!img || !img.isConnected) return; // 等待动画从进入 PENDING 状态就开始,并持续到放大图真正显示(LOADED/ACTIVE)。 // 不再用固定 300ms 的显示窗口,也不让动画时长与 config.delay 绑定。 const x = lastMouse.x; const y = lastMouse.y; if (x >= 0 && y >= 0) showHoverWaitIndicator(x, y, config.delay); } let pendingImageForIndicator = null; // ★ 光标是否在浏览器窗口内:mouseout(relatedTarget=null) 置 false,mousemove 置 true。 // 原生窗口(文件管理器等)覆盖浏览器时,DOM 对遮挡毫无感知, // elementFromPoint 和 lastMouse 都会给出"光标仍在图上"的假象,必须靠此标记纠偏 let pointerInWindow = true; // ★ 浏览器窗口当前是否有焦点。关闭“失焦时收起”后,用它区分 // “鼠标真正离开浏览器”与“浏览器被文件管理器/其他应用暂时盖住”。 let browserWindowFocused = true; // ★ 恢复保护只看这一个开关;原 suppressWindowExitUntil / resumeBlockX / resumeBlockY // 三处状态只有写入、从未被读取(保护逻辑并未接线),已删除以免误导后续维护。 let resumeBlockedUntilMouseMove = false; const currentDomain = getDomain(); // ★ 面板中央提示:配置面板打开时,所有提示统一显示在面板正中央 let panelToastEl = null, panelToastTimer = null; function showPanelCenterToast(message) { const overlay = document.getElementById('izModalOverlay'); const panel = overlay && overlay.querySelector('#izConfigPanel'); if (!panel || !overlay || overlay.style.display !== 'flex') return false; panel.style.position = 'relative'; if (!panelToastEl || panelToastEl.parentNode !== panel) { if (panelToastEl) panelToastEl.remove(); panelToastEl = document.createElement('div'); panelToastEl.id = 'izPanelToast'; panelToastEl.style.cssText = `position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) translateY(10px); background:rgba(15,23,42,.88);color:#fff;padding:10px 22px;border-radius:14px;font-size:13px;font-weight:500; font-family:Arial,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,.35);opacity:0;pointer-events:none;z-index:100006; max-width:360px;text-align:center;white-space:nowrap;transition:opacity .25s ease,transform .25s ease;`; panel.appendChild(panelToastEl); } panelToastEl.textContent = '✅ ' + message; void panelToastEl.offsetWidth; panelToastEl.style.opacity = '1'; panelToastEl.style.transform = 'translate(-50%,-50%) translateY(0)'; clearTimeout(panelToastTimer); panelToastTimer = setTimeout(() => { if (panelToastEl) { panelToastEl.style.opacity = '0'; panelToastEl.style.transform = 'translate(-50%,-50%) translateY(10px)'; } }, 1500); return true; } function showToast(message, duration = 2000) { if (showPanelCenterToast(message)) return; // 面板开着 → 显示在面板中央 let toast = document.getElementById('image-zoom-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'image-zoom-toast'; toast.style.cssText = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%); background:rgba(0,0,0,.8);color:#fff;padding:12px 20px;border-radius:6px;z-index:1000000; font-size:14px;font-family:Arial,sans-serif;opacity:0;transition:opacity .3s ease;pointer-events:none;`; document.body.appendChild(toast); } toast.textContent = message; toast.style.opacity = '1'; if (toast.timeoutId) clearTimeout(toast.timeoutId); // ★ 移除定时器必须与淡出定时器一起管理:旧提示进入 300ms 淡出阶段后, // 若新提示复用同一个元素,残留的移除回调会把刚显示的新提示一起删掉 //(表现为提示提前消失,之后又被重建)。 if (toast.removeTimerId) clearTimeout(toast.removeTimerId); toast.timeoutId = setTimeout(() => { toast.style.opacity = '0'; toast.removeTimerId = setTimeout(() => { if (toast && toast.parentNode) toast.parentNode.removeChild(toast); }, 300); }, duration); } let saveToastTimeout = null, saveToastEl = null; function showSaveToast(message) { if (showPanelCenterToast(message)) return; if (!saveToastEl || !saveToastEl.parentNode) { saveToastEl = document.createElement('div'); saveToastEl.id = 'image-zoom-save-toast'; saveToastEl.style.cssText = `position:fixed;bottom:30px;left:50%;transform:translateX(-50%); background:rgba(15,23,42,.88);color:#fff;padding:12px 24px;border-radius:16px;font-size:14px; font-weight:500;font-family:Arial,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,.25); opacity:0;transition:all .3s cubic-bezier(.16,1,.3,1);pointer-events:none;z-index:1000001; max-width:300px;text-align:center;`; document.body.appendChild(saveToastEl); } saveToastEl.textContent = '✅ ' + message; void saveToastEl.offsetWidth; saveToastEl.style.opacity = '1'; saveToastEl.style.transform = 'translateX(-50%) translateY(0)'; clearTimeout(saveToastTimeout); saveToastTimeout = setTimeout(() => { if (saveToastEl) { saveToastEl.style.opacity = '0'; saveToastEl.style.transform = 'translateX(-50%) translateY(16px)'; } }, 1500); } // ================ // 1. 配置模块 // ================ // ================ // 1. 配置 // ================ const defaultConfig = { delay: 800, scale: 2, maxWidth: 1200, maxHeight: 980, minScale: 1.4, zoomZIndex: 9999, scrollSpeed: 50, smallImgThreshold: 280, smallImgWidth: 500, smallImgHeight: 430, avoidClickConflict: true, blurDismiss: true, wheelZoom: true, zoomMode: 'adaptive', minOriginalSize: 51 }; const CONFIG_LIMITS = { delay: [0, 2000], scale: [1, 5], maxWidth: [300, 3000], maxHeight: [300, 3000], minScale: [1, 3], scrollSpeed: [5, 50], smallImgThreshold: [100, 500], smallImgWidth: [300, 1000], smallImgHeight: [300, 1000], minOriginalSize: [0, 500] }; let config = { ...defaultConfig }; let isEnabled = true; function validateConfig(cfg) { const v = { ...cfg }; for (const [key, [min, max]] of Object.entries(CONFIG_LIMITS)) { const val = parseFloat(v[key]); v[key] = isNaN(val) ? defaultConfig[key] : Math.max(min, Math.min(max, val)); } v.zoomMode = v.zoomMode === 'fixed' ? 'fixed' : 'adaptive'; v.blurDismiss = typeof v.blurDismiss === 'boolean' ? v.blurDismiss : true; v.wheelZoom = typeof v.wheelZoom === 'boolean' ? v.wheelZoom : true; return v; } function loadConfig() { const saved = storageGet(`image_zoom_config_${currentDomain}`); if (saved) config = { ...defaultConfig, ...validateConfig(saved) }; } function saveConfig() { storageSet(`image_zoom_config_${currentDomain}`, config); } function loadState() { isEnabled = storageGet(`image_zoom_enabled_${currentDomain}`) !== false; if (isHomepageZoomDisabled()) isEnabled = false; } // 主页禁用状态 function isHomepageDisabled() { return storageGet(`image_zoom_homepage_disabled_${currentDomain}`, false); } function isHomepageZoomDisabled() { return isHomepage() && isHomepageDisabled(); } // ================ // 2. ★★★ 触发资格判定(轮播 BUG 的根治点)★★★ // ================ // 此刻是否样式可见:fade 型轮播的 opacity:0 待播帧在这里被拒 function isImgVisibleNow(img) { if (!img || !img.isConnected) return false; try { const cs = window.getComputedStyle(img); if (cs.display === 'none' || cs.visibility === 'hidden') return false; if (parseFloat(cs.opacity) < 0.05) return false; if (typeof img.checkVisibility === 'function' && !img.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false; } catch (e) { } return true; } function isImgClippedAway(img) { if (!img || !img.isConnected) return true; const ir = img.getBoundingClientRect(); if (ir.width < 2 || ir.height < 2) return true; let node = img.parentElement; while (node && node !== document.body) { try { const cs = getComputedStyle(node); const hidden = (v) => v === 'hidden' || v === 'clip'; if (hidden(cs.overflowX) || hidden(cs.overflowY)) { const nr = node.getBoundingClientRect(); if (ir.right <= nr.left || ir.left >= nr.right || ir.bottom <= nr.top || ir.top >= nr.bottom) { return true; } } } catch (e) { } node = node.parentElement; } return false; } function inRect(x, y, r) { return !!r && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; } // ================================================================= // ★ 覆盖物性质判定(只服务触发资格,不碰状态机) // isMenuOverlay = 网站自己弹出的菜单/浮层 → 拦截 // isBlankCover = 无内容无样式的空白占位层 → 拦截 // 半透明哑遮罩(淘宝类盖图场景)→ 明确放行,不误杀 // canPierceBlocker = 装饰性空覆盖层(无文字、无媒体/交互子元素,且不在"覆盖式"语义浮层内) // → 允许穿透继续找下层图片,避免"图片被一层空 div 盖住就无法放大" // ================================================================= // ============================= // 🟡 COMPATIBILITY ZONE // 网站浮层/菜单兼容规则,谨慎修改 // ============================= function isMenuOverlay(el) { if (!el || el === document.body || el === document.documentElement) return false; // ★ el 自身是"空白透明层"时不是菜单:交给 isBlankCover 的规则 //(pointer 覆盖层/轮播点击区放行),否则会被有背景的祖先连坐误杀 if (isBlankCover(el)) return false; try { // 语义明确的 ARIA 浮层结构可以直接拦截。 // 但 role="dialog" 常被 SPA 用作普通内容包裹层:仅当它确实是"覆盖式"(fixed/absolute)才拦截, // 否则会把包住卡片的 dialog 当成阻挡菜单,误杀其中的图片。 const semLayer = el.closest('[role="menu"],[role="listbox"],[role="combobox"],[role="dialog"]'); if (semLayer) { const srole = semLayer.getAttribute('role'); if (srole === 'menu' || srole === 'listbox' || srole === 'combobox') return true; try { const scs = getComputedStyle(semLayer); if (scs.position === 'fixed' || scs.position === 'absolute') return true; } catch (e) { } } // ★ 向上检查有限层级:很多老式网站的弹窗实际命中的是内部 // span/div/td,而真正的 fixed + z-index 浮层根节点在更上层。 // 这样可阻止鼠标穿透弹窗去命中下面的图片。 let node = el; let depth = 0; while (node && node !== document.body && node !== document.documentElement && depth < 12) { const cs = getComputedStyle(node); const positioned = cs.position === 'fixed' || cs.position === 'absolute'; const zIndex = cs.zIndex !== 'auto' && parseFloat(cs.zIndex) > 0; const er = node.getBoundingClientRect(); if (er.width >= 64 && er.height >= 32 && positioned && zIndex) { const m = cs.backgroundColor.match(/rgba?\(([^)]+)\)/); const alpha = m ? (m[1].split(',').length === 4 ? parseFloat(m[1].split(',')[3]) : 1) : 0; const visibleSurface = alpha > 0.5 || cs.backgroundImage !== 'none'; const semanticOverlay = node.matches && node.matches('[role="menu"],[role="listbox"],[role="combobox"],[role="dialog"]'); const keywordOverlay = node.matches && node.matches( '[class*="dropdown" i],[class*="menu" i],[class*="popup" i],[class*="popover" i],[class*="subnav" i],' + '[class*="modal" i],[class*="dialog" i],[class*="drawer" i],[class*="sheet" i],' + '[class*="login" i],[class*="signin" i],[class*="sign-in" i],[class*="passport" i],' + '[class*="float" i],[class*="layer" i],[class*="mask" i],[class*="panel" i],[class*="fwin" i]' ); // ★ 关键词命中还须节点内有实际 UI 内容: // 弹窗外壳(NGA #fwin_login 内含 form)→拦;空的半透明盖图遮罩→放行走路径C const hasRealContent = node.querySelector ? !!node.querySelector('form,input,select,textarea,button,a') || !!(node.textContent || '').trim() : false; if (visibleSurface || semanticOverlay || (keywordOverlay && hasRealContent)) return true; } node = node.parentElement; depth++; } } catch (e) { } return false; } function isBlankCover(el) { if (!el || el === document.body || el === document.documentElement) return false; try { const cs = getComputedStyle(el); // 必须完全不可见:无背景色/图、无边框、无阴影 const bgT = cs.backgroundColor === 'transparent' || cs.backgroundColor === 'rgba(0, 0, 0, 0)' || /rgba\([^)]*,\s*0\)\s*$/.test(cs.backgroundColor); const invisible = bgT && cs.backgroundImage === 'none' && parseFloat(cs.borderTopWidth) === 0 && cs.boxShadow === 'none'; if (!invisible) return false; // 必须无内容:无文字、无任何媒体/交互子元素 if (el.textContent && el.textContent.trim() !== '') return false; if (el.querySelector('img, svg, video, canvas, iframe, button, a, input, select, textarea')) return false; // ★ pointer 光标的透明层放行(轮播换页点击区多是这种,避免误杀) if (cs.cursor === 'pointer') return false; // ★ 轮播/滑块容器内的透明层一律放行 if (el.closest('[class*="carousel"],[class*="slider"],[class*="swiper"],[class*="gallery"],[class*="slick"]')) return false; return true; } catch (e) { return false; } } function isHoverBlocker(el) { return isMenuOverlay(el) || isBlankCover(el); } // ★ 装饰性空覆盖层:无文字、无媒体/交互子元素。 // 典型:盖在图片上的点击层 / 渐变浮层 / 空占位 div。 // 例:Unsplash Discover 卡片
// —— overlay 是 的兄弟节点,盖在图片上,会被判定为 blocker 而挡住放大。 function isDecorativeCover(el) { if (!el || !el.querySelector) return false; try { if ((el.textContent || '').trim() !== '') return false; if (el.querySelector('img,svg,video,canvas,iframe,button,a,input,select,textarea,form,[role="button"],[role="link"]')) return false; return true; } catch (e) { return false; } } // ★ 是否允许"穿透"该拦截层继续找图: // 装饰性空层、且不在"覆盖式"语义浮层(菜单/弹窗/对话框,position:fixed/absolute)内 → 放行; // 真实菜单/弹窗即便内部为空也仍然拦截,避免悬停穿透浮层。 function canPierceBlocker(el) { if (!isDecorativeCover(el)) return false; // ★ 仅对"覆盖式"的语义浮层拒绝穿透。 // 若对任意 role=dialog/aria-modal 祖先都拒绝,被 dialog 包住的卡片会让覆盖层永远无法穿透。 const sem = el.closest && el.closest('[role="menu"],[role="listbox"],[role="combobox"],[role="dialog"],[aria-modal="true"]'); if (sem) { try { const cs = getComputedStyle(sem); if (cs.position === 'fixed' || cs.position === 'absolute') return false; } catch (e) { return false; } } return true; } // 实时资格:连接 + 样式可见 + 未被裁出可视区 + 尺寸达标 + 鼠标在实时矩形内 // 祖先锚(遮罩盖图场景):面积 ≤ 图片 8 倍、光标距图片矩形 ≤ 120px function canTriggerNow(img, x, y) { if (!isImgVisibleNow(img)) return null; const r = img.getBoundingClientRect(); if (r.width < config.minOriginalSize || r.height < config.minOriginalSize) return null; if (inRect(x, y, r)) { return isImgClippedAway(img) ? null : img; } const PROXIMITY = 120; const imgArea = r.width * r.height; const nearImg = (rect) => x >= rect.left - PROXIMITY && x <= rect.right + PROXIMITY && y >= rect.top - PROXIMITY && y <= rect.bottom + PROXIMITY; let node = img.parentElement; while (node && node !== document.body) { if (node.closest && node.closest('.image-zoom-container')) break; const nr = node.getBoundingClientRect(); const nArea = nr.width * nr.height; if (inRect(x, y, nr) && nearImg(nr) && nArea <= imgArea * 8 && nr.width < window.innerWidth * 0.9 && nr.height < window.innerHeight * 0.9) { return isImgClippedAway(img) ? null : node; } node = node.parentElement; } return null; } const COMMON_SELECTORS = [ '[onclick*="zoom"]', '[onclick*="lightbox"]', '[onclick*="gallery"]', '[onclick*="preview"]', '[data-action*="zoom"]', '[data-lightbox]', '[data-gallery]', '[data-fancybox]', '.zoomable', '.lightbox', '.gallery-item', '.fancybox', '.stretched-link' ]; function checkImageClickBehavior(img) { for (const selector of COMMON_SELECTORS) if (img.matches(selector)) return true; let parent = img.parentElement; while (parent && parent !== document.body) { const cls = (typeof parent.className === 'string' ? parent.className : (parent.getAttribute('class') || '')) || ''; const pid = parent.id || ''; if (/zoom|lightbox|gallery|fancybox|stretched-link/.test(cls) || /zoom|lightbox|gallery|fancybox/.test(pid)) return true; parent = parent.parentElement; } return false; } function isValidImage(img) { if (!img || img.tagName !== 'IMG' || !img.parentNode) return false; if (img.getClientRects().length === 0) return false; const style = window.getComputedStyle(img); if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) < 0.05) return false; const src = img.src || img.currentSrc; if (!src || src.trim() === '' || src.startsWith('data:') || src.includes('placeholder')) return false; // 现代图片站可能给真实 设置 background-image 作为模糊/占位底图。 // 只要 自身有真实 src、已加载且尺寸有效,就仍视为有效图片。 if (!img.complete || img.naturalWidth === 0) return false; const rect = img.getBoundingClientRect(); return !(rect.width < 10 || rect.height < 10); } // 2. ★★★ 触发资格判定 // ================ // 3. 图片处理工具 // ================ // 5.6.23 image URL/background URL helpers: moved verbatim from 5.6.22. function upgradeImgUrl(url) { if (!url) return url; return url.replace(/\/remote\/thumb\/\d+x\d+\//, '/'); } function extractBgUrl(el) { if (!el || el.nodeType !== 1) return null; let m = (el.getAttribute('style') || '').match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/i); let url = m ? m[1] : null; if (!url) { try { const bg = getComputedStyle(el).backgroundImage; if (bg && bg !== 'none') { m = bg.match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/i); if (m) url = m[1]; } } catch (e) { } } return url ? cleanBgUrl(url) : null; } function cleanBgUrl(url) { let u = url.trim().replace(/^['"]|['"]$/g, ''); if (/alicdn\.com/i.test(u)) { let prev; do { prev = u; u = u.replace(/(_!![\w\-.,]+?\.(?:jpg|jpeg|png|webp))_[\w.\-]+$/i, '$1') .replace(/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1') .replace(/_\.(webp|jpg|jpeg|png)$/i, ''); } while (u !== prev); return u; } let prev; do { prev = u; u = u.replace(/![\w\-]+$/i, '') .replace(/_\d+x\d+(q\d+)?\.(jpg|jpeg|png|webp)(\.\w+)?$/i, '') .replace(/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1') .replace(/_\.(webp|jpg|jpeg|png)$/i, '') .replace(/\.webp$/i, '.jpg'); } while (u !== prev); return u; } // 修复记录:本函数此前在文件内被重复定义两次(旧的一份带滚轮保护分支、被后一份整体覆盖), // 现已合并为唯一实现——两份的行为分支都保留在这里,不会再出现“改了不生效”的副本。 function cropBlackBars(imgEl) { try { if (imgEl.dataset.zoomCropped) return; const w = imgEl.naturalWidth, h = imgEl.naturalHeight; if (!w || !h) return; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(imgEl, 0, 0); let data; try { data = ctx.getImageData(0, 0, w, h).data; } catch (e) { return; } let transparentCount = 0; const total = w * h, step = Math.max(1, Math.floor(total / 20000)); let sampled = 0; for (let i = 0; i < total; i += step) { if (data[i * 4 + 3] < 10) transparentCount++; sampled++; } if (transparentCount / sampled > 0.05) return; const threshold = 24; const isContent = (i) => data[i + 3] > 10 && (data[i] > threshold || data[i + 1] > threshold || data[i + 2] > threshold); const rowHas = (y0) => { for (let x0 = 0; x0 < w; x0++) if (isContent((y0 * w + x0) * 4)) return true; return false; }; const colHas = (x0) => { for (let y0 = 0; y0 < h; y0++) if (isContent((y0 * w + x0) * 4)) return true; return false; }; let top = 0; while (top < h && !rowHas(top)) top++; if (top === h) return; let bottom = h - 1; while (bottom > top && !rowHas(bottom)) bottom--; let left = 0; while (left < w && !colHas(left)) left++; let right = w - 1; while (right > left && !colHas(right)) right--; top = Math.min(top, Math.floor(h * 0.25)); bottom = Math.max(bottom, h - 1 - Math.floor(h * 0.25)); left = Math.min(left, Math.floor(w * 0.25)); right = Math.max(right, w - 1 - Math.floor(w * 0.25)); const cw = right - left + 1, ch = bottom - top + 1; if (cw >= w * 0.9 && ch >= h * 0.9) return; if (cw < 20 || ch < 20) return; const out = document.createElement('canvas'); out.width = cw; out.height = ch; out.getContext('2d').drawImage(imgEl, left, top, cw, ch, 0, 0, cw, ch); imgEl.dataset.zoomCropped = '1'; out.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); if (imgEl.__zoomBlobUrl) URL.revokeObjectURL(imgEl.__zoomBlobUrl); imgEl.__zoomBlobUrl = url; imgEl.src = url; // ★ 滚轮缩放模式:裁剪完成后绝不能重新按容器 contain 重排。 // 否则异步 toBlob 回调会把用户刚刚滚轮放大的尺寸重置,表现为 // “第一滚轮先缩小一点”,竖图甚至会直接跳回最小尺寸。 // 同时让容器始终与实际图片尺寸一致,避免出现透明的大框。 // 注:当前唯一调用点是 !inst.wheelZoom 分支,所以下面这段滚轮保护 // 目前不会进入;保留它是为了保证将来放开“滚轮 + 裁剪”时尺寸语义仍然正确。 const box = imgEl.parentNode; const activeInst = box && box.__zoomInstance; if (box && box.classList.contains('image-zoom-container') && activeInst && activeInst.wheelZoom) { const currentZoom = Number.isFinite(activeInst.currentZoom) ? activeInst.currentZoom : 1; const visualW = parseFloat(imgEl.style.width) || box.clientWidth || w; const visualH = parseFloat(imgEl.style.height) || box.clientHeight || h; // 以裁剪前当前实际显示尺寸反推新的 zoom=1 基准,保持当前视觉大小不跳变。 activeInst.zoomBaseW = Math.max(1, visualW / currentZoom); activeInst.zoomBaseH = Math.max(1, visualH / currentZoom); const nw = Math.max(1, Math.round(activeInst.zoomBaseW * currentZoom)); const nh = Math.max(1, Math.round(activeInst.zoomBaseH * currentZoom)); box.style.setProperty('width', nw + 'px', 'important'); box.style.setProperty('height', nh + 'px', 'important'); box.style.setProperty('max-width', 'none', 'important'); box.style.setProperty('max-height', 'none', 'important'); box.style.setProperty('overflow', 'visible', 'important'); imgEl.style.setProperty('width', nw + 'px', 'important'); imgEl.style.setProperty('height', nh + 'px', 'important'); imgEl.style.setProperty('left', '0px', 'important'); imgEl.style.setProperty('top', '0px', 'important'); imgEl.style.setProperty('object-fit', 'fill', 'important'); } else if (box && box.classList.contains('image-zoom-container')) { // 容器尺寸保持不变(尊重当前模式的尺寸语义), // 只按新宽高比在容器内做 contain 重排 const bw = box.clientWidth, bh = box.clientHeight; const ratio = cw / ch; let nw = bw, nh = Math.round(bw / ratio); if (nh > bh) { nh = bh; nw = Math.round(bh * ratio); } imgEl.style.width = nw + 'px'; imgEl.style.height = nh + 'px'; imgEl.style.left = Math.round((bw - nw) / 2) + 'px'; imgEl.style.top = Math.round((bh - nh) / 2) + 'px'; } }, 'image/jpeg', 0.92); } catch (e) { } } // ================ // 7. 站点规则(背景图模式) // ================ const SITE_HOVER_PROXY_RULES = [ { domains: ['taobao.com', 'tmall.com'], itemSelector: '.img-wrapper', cardSelector: '.tb-pick-content-item, li', pollInterval: 300 } ]; function getCustomRules() { try { return storageGet('image_zoom_custom_rules', []); } catch (e) { return []; } } function saveCustomRules(rules) { storageSet('image_zoom_custom_rules', rules); } // ★ 空字符串选择器不能直接丢给 closest()/querySelectorAll(): // closest('') 会抛 SyntaxError("The provided selector is empty")。 // 规则里的卡片选择器允许留空(拾取器找不到卡片祖先时正是存空串), // 统一在这里归一为「非空字符串 或 null」,使用处再按 null 跳过。 function toSelector(v) { return (typeof v === 'string' && v.trim()) ? v.trim() : null; } // ================ // 背景图悬停模块 // ================ function setupBgRuleProxy() { const customRules = getCustomRules() .filter(r => r.enabled && r.imgMode === 'background') .map(r => ({ domains: String(r.domains || '').split(',').map(s => s.trim()).filter(Boolean), itemSelector: toSelector(r.itemSelector), cardSelector: toSelector(r.cardSelector), pollInterval: Math.max(100, parseInt(r.pollInterval) || 300) })) .filter(r => r.itemSelector && r.domains.length); const domainMatch = (r) => r.domains.some(d => currentDomain === d || currentDomain.endsWith('.' + d)); const rule = customRules.find(domainMatch) || SITE_HOVER_PROXY_RULES.find(domainMatch); if (!rule) return; let lastBgCard = null, bgDelayTimer = null; setInterval(() => { if (document.hidden) return; if (!pointerInWindow) return; // ★ 光标不在浏览器内,不做背景图识别 if (!isEnabled || isHomepageZoomDisabled()) return; if (zoomFSM.hasActiveZoom()) return; const x = lastMouse.x, y = lastMouse.y; if (x < 0) return; const el = document.elementFromPoint(x, y); if (!el || !el.closest) return; let wrapper = el.closest(rule.itemSelector); if (!wrapper) { // ★ 卡片选择器可能为空(归一为 null),空串会让 closest() 抛 SyntaxError, // 而定时代码每 pollInterval 跑一次,异常会持续刷控制台。 const card0 = rule.cardSelector ? el.closest(rule.cardSelector) : null; if (card0) { const w = card0.querySelector(rule.itemSelector); if (w) { const r = w.getBoundingClientRect(); if (inRect(x, y, r)) wrapper = w; } } } if (wrapper) { const card = (rule.cardSelector ? el.closest(rule.cardSelector) : null) || wrapper; if (card !== lastBgCard) { lastBgCard = card; bgZoomLayer.hide(); if (bgDelayTimer) clearTimeout(bgDelayTimer); const mx = x, my = y; bgDelayTimer = setTimeout(() => { bgDelayTimer = null; if (Math.abs(lastMouse.x - mx) < 20 && Math.abs(lastMouse.y - my) < 20) { const url = extractBgUrl(wrapper); if (url) bgZoomLayer.show({ cleaned: url, raw: url }, 1); } else { // ★ 本次延迟显示已放弃,lastBgCard 必须回滚: // 否则回到同一张卡片时 card === lastBgCard,背景图放大永久失效。 lastBgCard = null; } }, config.delay); } } else { const card = rule.cardSelector ? el.closest(rule.cardSelector) : null; if (!card || !card.querySelector(rule.itemSelector)) { if (bgDelayTimer) { clearTimeout(bgDelayTimer); bgDelayTimer = null; } bgZoomLayer.hide(); lastBgCard = null; } } }, rule.pollInterval); } // ================ // 8. 背景图自动识别兜底 // ================ const bgZoomLayer = (function() { let container = null, url = null; function hide() { url = null; if (!container) return; const c = container; container = null; const im = c.querySelector('img'); if (im) im.style.transform = 'scale(.6)'; c.style.opacity = '0'; setTimeout(() => c.remove(), 280); } function show(loadUrls, zOffset) { if (container && !container.isConnected) { container = null; url = null; } if (container && url === loadUrls.cleaned) return; hide(); zoomFSM.dispatch('RESET'); // 背景图接管前硬重置 FSM,避免悬空引用 const c = document.createElement('div'); c.className = 'image-zoom-container'; c.dataset.izOwner = 'bg'; c.style.cssText = `position:fixed;inset:0;z-index:${config.zoomZIndex - (zOffset || 1)};opacity:0; transition:all .3s ease;pointer-events:none;display:flex;justify-content:center;align-items:center; padding:20px;box-sizing:border-box;`; const big = document.createElement('img'); big.style.cssText = `max-width:${Math.min(window.innerWidth - 60, config.maxWidth)}px; max-height:${Math.min(window.innerHeight - 60, config.maxHeight)}px;object-fit:contain;border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,.2);transform:scale(.6);transition:transform .35s cubic-bezier(.34,1.56,.64,1);`; let triedFallback = false; big.onerror = () => { if (!triedFallback && loadUrls.raw && loadUrls.raw !== big.src) { triedFallback = true; big.src = loadUrls.raw; return; } hide(); }; big.onload = () => requestAnimationFrame(() => { c.style.opacity = '1'; big.style.transform = 'scale(1)'; }); big.src = loadUrls.cleaned; c.appendChild(big); document.body.appendChild(c); container = c; url = big.src; } return { show, hide }; })(); function setupAutoBackgroundHover() { let bgTimer = null, pendingUrl = null; const cancelBg = () => { if (bgTimer) { clearTimeout(bgTimer); bgTimer = null; } pendingUrl = null; }; document.addEventListener('mouseover', throttleLeading((e) => { if (!isEnabled || isHomepageZoomDisabled()) { cancelBg(); bgZoomLayer.hide(); return; } if (config.avoidClickConflict && isImageInLightboxMode()) { cancelBg(); bgZoomLayer.hide(); return; } if (e.target.closest && e.target.closest('#zoomDockZone, #izModalOverlay, #izIntroOverlay, #izUpdateNotice, .image-zoom-container')) return; if (isHoverBlocker(e.target) && !canPierceBlocker(e.target)) { cancelBg(); bgZoomLayer.hide(); return; } if (zoomFSM.hasActiveZoom()) { cancelBg(); return; } const x = e.clientX, y = e.clientY; // 光标下已有合格 img → 不做背景图识别 if (pickVisibleImgUnderPoint(x, y)) { cancelBg(); return; } if (!(e.target instanceof Element)) { cancelBg(); return; } let node = e.target, bgEl = null, bgFromSibling = false; while (node && node !== document.body) { try { const bg = getComputedStyle(node).backgroundImage; if (bg && bg !== 'none' && bg.includes('url(')) { bgEl = node; break; } } catch (err) { } if (node.querySelectorAll) { const kids = node.querySelectorAll('*'); if (kids.length <= 500) { for (const kid of kids) { if (kid === node) continue; try { const bg2 = getComputedStyle(kid).backgroundImage; if (bg2 && bg2 !== 'none' && bg2.includes('url(')) { const kr = kid.getBoundingClientRect(); if (kr.width >= 120 && kr.height >= 120 && inRect(x, y, kr)) { bgEl = kid; bgFromSibling = true; break; } } } catch (err) { } } if (bgEl) break; } } node = node.parentElement; } if (!bgEl) { cancelBg(); bgZoomLayer.hide(); return; } if (!bgFromSibling) { const cs = getComputedStyle(bgEl); if (cs.backgroundRepeat.split(' ').some(v => v.startsWith('repeat') && v !== 'no-repeat')) { cancelBg(); bgZoomLayer.hide(); return; } if (cs.backgroundPosition !== '0% 0%' && cs.backgroundSize !== 'cover' && cs.backgroundSize !== 'contain' && cs.backgroundSize !== '100% 100%') { cancelBg(); bgZoomLayer.hide(); return; } } // 占屏接近全屏的背景基本是装饰底图,放大只会挡住页面内容,不是用户意图 try { const br = bgEl.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; const isDecorative = (br.width >= vw * 0.7 && br.height >= vh * 0.6) || // 宽屏 hero/banner (br.width >= vw * 0.5 && br.height >= vh * 0.85) || // 竖向大背景 (br.width * br.height >= vw * vh * 0.5); // 面积过半屏 if (isDecorative || getComputedStyle(bgEl).backgroundAttachment === 'fixed') { cancelBg(); bgZoomLayer.hide(); return; } } catch (err) { } const url = extractBgUrl(bgEl); if (!url) { cancelBg(); bgZoomLayer.hide(); return; } if (url === pendingUrl && bgTimer) return; cancelBg(); pendingUrl = url; if (config.delay <= 0) { bgZoomLayer.show({ cleaned: url, raw: url }, 2); return; } bgTimer = setTimeout(() => { bgTimer = null; const r = bgEl.isConnected ? bgEl.getBoundingClientRect() : null; if (!inRect(lastMouse.x, lastMouse.y, r) || zoomFSM.hasActiveZoom()) { pendingUrl = null; return; } bgZoomLayer.show({ cleaned: url, raw: url }, 2); }, config.delay); }, 100), true); document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { cancelBg(); bgZoomLayer.hide(); } }, true); } // 5.6.38 - 图片登记处理器 // 不改 processImage 行为。 // ================ // 4. 图片登记 // ================ function processImage(img) { if (!isEnabled || !img || !img.parentNode) return; // ★ 自有 UI 隔离:放大层自身的 也是 document.body 子树里的 IMG, // 会被 MutationObserver 当成页面新图送进来(追加容器 → 触发 childList)。 // 这里统一拦掉,避免给自己的预览层打上 image-zoom-processed 并多跑一轮登记。 if (img.closest && img.closest('.image-zoom-container')) return; try { if (!isValidImage(img)) return; if (img.classList.contains('image-zoom-processed')) return; img.classList.add('image-zoom-processed'); img.__zoomHasClick = config.avoidClickConflict ? checkImageClickBehavior(img) : false; if (!img.__zoomHasClick) { img.classList.add('image-zoom-hover'); const p = img.parentNode; if (p && p !== document.body) p.classList.add('image-zoom-hover'); } } catch (error) { } } // ================ // 4. 图片登记 // ================ // ================ // 5. ★ 核心:单实例状态机 zoomFSM // ================ // 6. ★ 全局事件流 + 停稳裁决器 // ============================= // 🔴 CORE PROTECTION ZONE // target resolve / zoom self filtering // ============================= function pickVisibleImgUnderPoint(x, y) { let stack = []; try { stack = document.elementsFromPoint(x, y) || []; } catch (e) { } for (const el of stack) { // 🔴 防止自身放大层污染 elementsFromPoint 结果 if (el && el.closest && el.closest('.image-zoom-container')) { continue; } if (el.tagName !== 'IMG') { // ★ 从顶往下扫,先碰到菜单/空白占位 → 判定无图,不再穿透 if (el !== document.body && el !== document.documentElement && isHoverBlocker(el) && !canPierceBlocker(el)) return null; continue; } if (!isImgVisibleNow(el)) continue; const r = el.getBoundingClientRect(); if (r.width >= config.minOriginalSize && r.height >= config.minOriginalSize && r.width >= 10 && r.height >= 10 && inRect(x, y, r)) { return el; } } return null; } // ★ 统一裁决:光标位置 (x,y) 下是什么?派发 HOVER / HOVER_NONE / 保持 // 被 mouseover 流(事件坐标)和停稳裁决器(lastMouse 坐标)共用 function resolveCursorTarget(x, y, t) { if (!isEnabled || isHomepageZoomDisabled()) { zoomFSM.dispatch('DISMISS'); return; } // 网页自身进入灯箱后:旧的 hover 放大立即收起,且灯箱内部图片不再触发新的放大。 if (isImageInLightboxMode(t)) { zoomFSM.dispatch('HOVER_NONE', { x, y, force: true }); return; } if (t && t.closest && t.closest('#zoomDockZone, #izModalOverlay, #izIntroOverlay, #izUpdateNotice')) { zoomFSM.dispatch('HOVER_NONE', { x, y }); return; } if (t && t.closest && t.closest('.image-zoom-container')) return; let img = null; // 路径A:target 是 img(可见 + 在矩形内 + 未被裁出可视区) if (t && t.tagName === 'IMG' && isImgVisibleNow(t) && inRect(x, y, t.getBoundingClientRect()) && !isImgClippedAway(t)) img = t; // 路径B:命中栈选图 if (!img) img = pickVisibleImgUnderPoint(x, y); // 路径B+:stretched-link / card-link 整卡链接桥接 if (!img && t && t.closest) { const link = t.closest('a.stretched-link, a.card-link'); if (link) { let node = link.parentElement, hops = 0; while (node && node !== document.body && hops < 4) { for (const cImg of node.querySelectorAll('img')) { // ★ 自有 UI 隔离:放大层 img 不作为桥接目标 if (cImg.closest && cImg.closest('.image-zoom-container')) continue; if (isImgVisibleNow(cImg) && !isImgClippedAway(cImg) && inRect(x, y, cImg.getBoundingClientRect())) { img = cImg; break; } } if (img) break; node = node.parentElement; hops++; } } } // 路径B++:同一 内的标题/文本桥接 // 仅在链接内恰好存在 1 张合格图片、且没有按钮/表单控件时启用, // 避免把普通导航、商品操作区等整卡误判成图片触发区。 if (!img && t && t.closest) { const anchor = t.closest('a'); if (anchor && anchor !== document.body && anchor !== document.documentElement) { const ar = anchor.getBoundingClientRect(); if (inRect(x, y, ar)) { const hasInteractive = !!anchor.querySelector('button,input,select,textarea,[role="button"],[role="menuitem"]'); if (!hasInteractive) { const candidates = Array.from(anchor.querySelectorAll('img')).filter(cImg => !cImg.closest('.image-zoom-container') && isImgVisibleNow(cImg) && !isImgClippedAway(cImg) && cImg.naturalWidth > 0 && cImg.naturalHeight > 0 ); if (candidates.length === 1) { const cImg = candidates[0]; const cr = cImg.getBoundingClientRect(); if (cr.width >= config.minOriginalSize && cr.height >= config.minOriginalSize) { img = cImg; } } } } } } // 路径C:遮罩盖图 —— 局部扫描 if (!img) { if (t && t !== document.body && isHoverBlocker(t) && !canPierceBlocker(t)) { zoomFSM.dispatch('HOVER_NONE', { x, y }); return; } const scope = (t && t.closest && t.closest('a, article, section, li, picture, div[class]')) || document; const imgs = scope.querySelectorAll('img'); for (const im of imgs) { // ★ 自有 UI 隔离:scope=document 时会扫到放大层自身 img, // 否则将自举 HOVER → 对放大图再开实例(图上叠图) if (im.closest && im.closest('.image-zoom-container')) continue; const r = im.getBoundingClientRect(); if (r.width >= 80 && r.height >= 80 && r.width >= config.minOriginalSize && r.height >= config.minOriginalSize && inRect(x, y, r) && isImgVisibleNow(im) && !isImgClippedAway(im)) { img = im; break; } } } if (img) { if (!img.classList.contains('image-zoom-processed')) processImage(img); if (img.classList.contains('image-zoom-processed')) { zoomFSM.dispatch('HOVER', { img, x, y }); return; } } zoomFSM.dispatch('HOVER_NONE', { x, y }); } // ================ // 5. ★ 核心:单实例状态机 zoomFSM // ================ const zoomFSM = (function() { const S = Object.freeze({ IDLE:'IDLE', PENDING:'PENDING', SHOWING:'SHOWING', ACTIVE:'ACTIVE', FADING:'FADING' }); let state = S.IDLE; let instance = null; // 唯一活实例 let generation = 0; // 代际计数:每次目标变化/离开/切换 +1,旧异步任务据此丢弃 let currentAbort = null; // 当前在途媒体加载的取消控制器(leave/switch 时 abort) let pendingImg = null; // PENDING 中等待的图 let pendingTimer = null; let pendingFails = 0; // 心跳连续失败计数(容忍轮播动画的瞬时错位) let pendingGraceUsed = false; // TIMER_FIRE 宽限重试是否已用 let wheelTicking = false, lastWheelEvent = null; function clearPending() { if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } hideHoverWaitIndicator(); pendingImg = null; pendingImageForIndicator = null; pendingFails = 0; pendingGraceUsed = false; } function fadeOutContainer(container) { if (!container || !container.parentNode) return; container.dataset.izFading = '1'; container.style.opacity = '0'; const zImg = container.querySelector('img'); if (zImg) { zImg.style.transform = 'scale(.6)'; zImg.style.opacity = '0'; if (zImg.__zoomBlobUrl) { URL.revokeObjectURL(zImg.__zoomBlobUrl); zImg.__zoomBlobUrl = null; } } setTimeout(() => { if (container.parentNode) container.parentNode.removeChild(container); }, FADE_MS); } // 按图片当前的天然比例,在容器内做一次 contain 适配(非滚轮模式专用)。 // 算法与 cropBlackBars 的非滚轮分支保持一致,确保两条路径结果相同。 function refitInstanceImage(inst) { if (!inst || !inst.container || !inst.imgEl) return; const box = inst.container, im = inst.imgEl; const nw0 = Number(im.naturalWidth) || 0, nh0 = Number(im.naturalHeight) || 0; const ratio = (nw0 > 0 && nh0 > 0) ? (nw0 / nh0) : (Number(inst.imageRatio) > 0 ? inst.imageRatio : 0); if (!(ratio > 0)) return; const bw = box.clientWidth, bh = box.clientHeight; if (!(bw > 0 && bh > 0)) return; let nw = bw, nh = Math.round(bw / ratio); if (nh > bh) { nh = bh; nw = Math.round(bh * ratio); } im.style.width = nw + 'px'; im.style.height = nh + 'px'; im.style.left = Math.round((bw - nw) / 2) + 'px'; im.style.top = Math.round((bh - nh) / 2) + 'px'; } function computeAdaptiveSize(img, rect) { const availW = Math.min(window.innerWidth - 60, config.maxWidth); const availH = Math.min(window.innerHeight - 60, config.maxHeight); const size = Math.sqrt(rect.width * rect.height); const availSize = Math.sqrt(availW * availH); const TARGET_MIN = Math.min(1200, availSize); let target = Math.min(Math.max(size * 5, TARGET_MIN), availSize); let scale = Math.min(Math.max(target / size, 1), 10); let w = Math.round(rect.width * scale), h = Math.round(rect.height * scale); const rawW = w, rawH = h; if (w > availW || h > availH) { const fit = Math.min(availW / w, availH / h); w = Math.round(w * fit); h = Math.round(h * fit); } return { w, h, rawW, rawH }; } function createInstance(img) { try { const rect = img.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return null; const isSmallImg = rect.width < config.smallImgThreshold || rect.height < config.smallImgThreshold; let boxW, boxH, imgScale = 1; if (config.zoomMode === 'adaptive') { const size = computeAdaptiveSize(img, rect); boxW = size.w; boxH = size.h; imgScale = 1; // 容器已按图片原始比例校正,scale=1 → 完整显示不裁切 } else if (isSmallImg) { const effScale = Math.max(config.scale || 1, config.minScale || 1); const targetW = Math.max(config.smallImgWidth, rect.width * effScale); const targetH = Math.max(config.smallImgHeight, rect.height * effScale); const fit = Math.min(targetW / rect.width, targetH / rect.height); boxW = Math.min(Math.round(rect.width * fit), config.maxWidth); boxH = Math.min(Math.round(rect.height * fit), config.maxHeight); } else { const effScale = Math.max(config.scale || 1, config.minScale || 1); boxW = Math.round(rect.width * effScale); boxH = Math.round(rect.height * effScale); if (boxW > config.maxWidth || boxH > config.maxHeight) { const fit = Math.min(config.maxWidth / boxW, config.maxHeight / boxH); boxW = Math.round(boxW * fit); boxH = Math.round(boxH * fit); } } // wheel 缩放模式建立独立缩放坐标系: // zoom=1 的宽度等于原缩略图渲染宽度,高度严格按实际图片比例推导。 // 这样不会把缩略图比例误当成原图比例,也不会生成透明的横向容器。 const naturalW = Number(img.naturalWidth) || 0; const naturalH = Number(img.naturalHeight) || 0; const naturalRatio = naturalW > 0 && naturalH > 0 ? naturalW / naturalH : (rect.width / Math.max(1, rect.height)); if (naturalRatio > 0) { const boxRatio = boxW / Math.max(1, boxH); if (naturalRatio > boxRatio) boxH = Math.max(1, Math.round(boxW / naturalRatio)); else boxW = Math.max(1, Math.round(boxH * naturalRatio)); } let wheelBaseW = Math.max(1, rect.width); let wheelBaseH = Math.max(1, Math.round(wheelBaseW / naturalRatio)); let wheelInitialZoom = 1; if (config.wheelZoom) { // 先保留现有首次放大尺寸,再反推出倍率;第一次滚轮就在这个尺寸上继续放大。 wheelInitialZoom = Math.max(1, boxW / wheelBaseW); boxW = Math.max(1, Math.round(wheelBaseW * wheelInitialZoom)); boxH = Math.max(1, Math.round(wheelBaseH * wheelInitialZoom)); } const imgW = config.wheelZoom ? boxW : Math.round(boxW * imgScale); const imgH = config.wheelZoom ? boxH : Math.round(boxH * imgScale); const offX = Math.round((boxW - imgW) / 2); const offY = Math.round((boxH - imgH) / 2); const zi = img.__zoomHasClick ? config.zoomZIndex - 1 : config.zoomZIndex; const container = document.createElement('div'); container.className = 'image-zoom-container'; container.dataset.izOwner = 'fsm'; container.style.cssText = `position:fixed;z-index:${zi};opacity:0;transition:opacity .3s ease; pointer-events:none;left:50%;top:50%;transform:translate(-50%,-50%); width:${boxW}px;height:${boxH}px;max-width:none;max-height:none;box-sizing:border-box;border-radius:10px;overflow:visible;`; const zoomedImg = document.createElement('img'); zoomedImg.alt = ''; zoomedImg.style.cssText = `position:absolute;left:${offX}px;top:${offY}px;width:${imgW}px;height:${imgH}px; object-fit:fill;max-width:none;max-height:none;transition:opacity .3s ease; box-shadow:0 4px 20px rgba(0,0,0,.2);display:block;border-radius:10px;transform:scale(.6);opacity:0;`; const fallbackSrc = img.src || img.currentSrc; let hiResSrc = null; if (!(currentDomain === 'jd.com' || currentDomain.endsWith('.jd.com'))) { hiResSrc = upgradeImgUrl((fallbackSrc || '') .replace(/\/s\d+x\d+_/g, '/') .replace(/\.avif$/i, '')) || null; } if (hiResSrc === fallbackSrc) hiResSrc = null; const initialZoom = config.wheelZoom ? wheelInitialZoom : Math.max(1, Math.min(5, Math.min(boxW / rect.width, boxH / rect.height))); const inst = { container, imgEl: zoomedImg, sourceImg: img, generation, // 创建时代际;异步回调据此判断是否过期 fallbackSrc, usingHiRes: false, revealed: false, sourceW: rect.width, sourceH: rect.height, currentZoom: initialZoom, wheelZoom: !!config.wheelZoom, zoomBaseW: config.wheelZoom ? wheelBaseW : rect.width, zoomBaseH: config.wheelZoom ? wheelBaseH : rect.height, imageRatio: naturalRatio }; container.__zoomInstance = inst; zoomedImg.onload = () => { if (inst.generation !== generation || instance !== inst) return; // 过期代际/实例回调直接丢弃 if (inst.wheelZoom && zoomedImg.naturalWidth > 0 && zoomedImg.naturalHeight > 0) { const nextRatio = zoomedImg.naturalWidth / zoomedImg.naturalHeight; if (nextRatio > 0 && Math.abs(nextRatio - inst.imageRatio) > 0.0001) { inst.imageRatio = nextRatio; inst.zoomBaseH = Math.max(1, inst.zoomBaseW / nextRatio); const w = Math.max(1, Math.round(inst.zoomBaseW * inst.currentZoom)); const h = Math.max(1, Math.round(inst.zoomBaseH * inst.currentZoom)); inst.container.style.setProperty('width', w + 'px', 'important'); inst.container.style.setProperty('height', h + 'px', 'important'); zoomedImg.style.setProperty('width', w + 'px', 'important'); zoomedImg.style.setProperty('height', h + 'px', 'important'); } } // ★ 非滚轮模式:容器尺寸在 createInstance 里定好后不再变化(尺寸语义固定)。 // 但这里加载成功的图未必就是建实例时那张——最典型的是后台探活通过后 // 替换成的高清图,宽高比可能与缩略图不同。此时若不按新比例重做一次 // contain 适配,内联的 object-fit:fill 会把图片拉伸变形 //(cropBlackBars 在“几乎无黑边”时会提前 return,兜不住这种情况)。 // 比例一致时计算结果与建实例时完全相同,不会产生任何视觉变化。 if (!inst.wheelZoom && zoomedImg.naturalWidth > 0 && zoomedImg.naturalHeight > 0) { refitInstanceImage(inst); } // 滚轮缩放模式不参与异步黑边裁剪:裁剪会改变图片天然比例并在 toBlob 回调中重新布局, // 从而造成首个滚轮“先缩小一下”以及竖图跳回最小尺寸/出现透明框。 if (!inst.wheelZoom) cropBlackBars(zoomedImg); if (!inst.revealed) { inst.revealed = true; FSM.dispatch('LOADED', inst); } }; zoomedImg.onerror = () => { if (inst.generation === generation && instance === inst) FSM.dispatch('ERROR', inst); }; zoomedImg.src = fallbackSrc; if (hiResSrc) { const ac = currentAbort; const probe = new Image(); if (ac) ac.signal.addEventListener('abort', () => { probe.src = ''; }); probe.onload = () => { if (ac && ac.signal.aborted) return; if (inst.generation !== generation) return; if (!zoomedImg.isConnected || zoomedImg.src === hiResSrc) return; if (zoomedImg.__zoomBlobUrl) { URL.revokeObjectURL(zoomedImg.__zoomBlobUrl); zoomedImg.__zoomBlobUrl = null; } delete zoomedImg.dataset.zoomCropped; inst.usingHiRes = true; zoomedImg.src = hiResSrc; }; probe.src = hiResSrc; } if (zoomedImg.complete && zoomedImg.naturalWidth > 0 && !inst.revealed) { inst.revealed = true; setTimeout(() => { if (inst.generation === generation && instance === inst) FSM.dispatch('LOADED', inst); }, 10); } container.appendChild(zoomedImg); document.body.appendChild(container); return inst; } catch (error) { console.warn('[FSM] 创建放大实例失败:', error); return null; } } const actions = { startPending(img) { clearPending(); pendingImg = img; pendingImageForIndicator = img; scheduleHoverWaitIndicator(img); pendingTimer = setTimeout(() => FSM.dispatch('TIMER_FIRE', img), config.delay); state = S.PENDING; }, cancel() { clearPending(); generation++; // ★ 作废在途异步任务 if (currentAbort) { currentAbort.abort(); currentAbort = null; } state = S.IDLE; }, show(img) { // ★ 等待动画保持显示,直到该实例真正 LOADED/ACTIVE。 // 新目标:作废上一代在途任务并开启新代(ADR-002) if (currentAbort) currentAbort.abort(); currentAbort = new AbortController(); generation++; const inst = createInstance(img); if (!inst) { hideHoverWaitIndicator(); state = S.IDLE; wheelManager.sync(); return; } instance = inst; state = S.SHOWING; wheelManager.sync(); }, activate(inst) { if (inst.generation !== generation || inst !== instance) return; hideHoverWaitIndicator(); inst.container.style.opacity = '1'; inst.imgEl.style.transform = 'scale(1)'; inst.imgEl.style.opacity = '1'; // 图片超出视口时提示当前滚轮操作(1200ms 后自动消失,避免遮挡) if (inst.container.offsetHeight > window.innerHeight && !inst.toastShown) { inst.toastShown = true; showToast(config.wheelZoom ? '滚动滚轮缩放图片' : '图片超出屏幕,滚动滚轮查看其余部分', 800); } state = S.ACTIVE; }, onError(inst) { if (inst.generation !== generation || inst !== instance) return; if (inst.usingHiRes) { inst.usingHiRes = false; inst.imgEl.src = inst.fallbackSrc; return; } if (currentAbort) { currentAbort.abort(); currentAbort = null; } fadeOutContainer(inst.container); instance = null; state = S.IDLE; wheelManager.sync(); }, beginFade() { clearPending(); generation++; // ★ 离开/切换:立即作废在途任务(ADR-003) if (currentAbort) { currentAbort.abort(); currentAbort = null; } if (instance) { fadeOutContainer(instance.container); instance = null; } state = S.FADING; setTimeout(() => { if (state === S.FADING) state = S.IDLE; }, FADE_MS); wheelManager.sync(); }, zoomByWheel(e) { if (!instance) return; const c = instance.container, im = instance.imgEl; const sourceW = Number(instance.sourceW) || (instance.sourceImg ? instance.sourceImg.getBoundingClientRect().width : 0); const sourceH = Number(instance.sourceH) || (instance.sourceImg ? instance.sourceImg.getBoundingClientRect().height : 0); if (!(sourceW > 0 && sourceH > 0)) return; // 缩放使用乘法倍率,避免不同基础倍率下出现“滚轮一下反而缩小一点”的视觉抖动。 // 向上固定放大约 8%,向下固定缩小约 7.4%,并始终以 1× 为下限。 const ZOOM_IN = 1.08; const ZOOM_OUT = 1 / 1.08; const direction = e.deltaY < 0 ? 1 : -1; const current = Number.isFinite(instance.currentZoom) ? instance.currentZoom : 1; const naturalW = Number(instance.sourceImg && instance.sourceImg.naturalWidth) || 0; const naturalH = Number(instance.sourceImg && instance.sourceImg.naturalHeight) || 0; // 最大倍率根据原图分辨率动态计算:允许明显超出页面,但避免生成失控的超大 DOM。 // 没有可用原图尺寸时使用保守上限。 const basePixels = naturalW > 0 && naturalH > 0 ? naturalW * naturalH : 0; const resolutionZoom = basePixels > 0 ? Math.sqrt((12000000) / basePixels) * Math.max(1, Math.min(4, Math.max(naturalW, naturalH) / 1200)) : 24; const maxZoom = Math.max(20, Math.min(50, resolutionZoom)); const nextZoom = Math.max(1, Math.min(maxZoom, current * (direction > 0 ? ZOOM_IN : ZOOM_OUT))); if (Math.abs(nextZoom - current) < 0.0001) return; instance.currentZoom = nextZoom; // 宽高始终由同一个缩放倍率计算,绝不分别缩放,保证横竖图比例恒定。 // 缩放基准固定为实例当前的 zoom=1 尺寸。 // 裁剪、高清图替换等异步过程不会再把这个基准重置成容器尺寸。 const baseW = Number(instance.zoomBaseW) > 0 ? instance.zoomBaseW : sourceW; const ratio = Number(instance.imageRatio) > 0 ? instance.imageRatio : (sourceW / Math.max(1, sourceH)); const baseH = Number(instance.zoomBaseH) > 0 ? instance.zoomBaseH : (baseW / ratio); const w = Math.max(1, Math.round(baseW * nextZoom)); const h = Math.max(1, Math.round(baseH * nextZoom)); c.style.setProperty('width', w + 'px', 'important'); c.style.setProperty('height', h + 'px', 'important'); c.style.setProperty('max-width', 'none', 'important'); c.style.setProperty('max-height', 'none', 'important'); c.style.setProperty('overflow', 'visible', 'important'); c.style.transform = 'translate(-50%, -50%)'; instance.panY = 0; im.style.setProperty('left', '0px', 'important'); im.style.setProperty('top', '0px', 'important'); im.style.setProperty('width', w + 'px', 'important'); im.style.setProperty('height', h + 'px', 'important'); im.style.setProperty('max-width', 'none', 'important'); im.style.setProperty('max-height', 'none', 'important'); im.style.setProperty('min-width', '0', 'important'); im.style.setProperty('min-height', '0', 'important'); im.style.setProperty('object-fit', 'contain', 'important'); im.style.setProperty('transform', 'none', 'important'); }, pan(e) { if (!instance) return; const c = instance.container, im = instance.imgEl; const move = e.deltaY > 0 ? -config.scrollSpeed : config.scrollSpeed; // 情况1:img 超出容器 → 容器内平移 img const imgOverflow = im.offsetHeight - c.clientHeight; if (imgOverflow > 0) { const minTop = Math.min(0, -imgOverflow); const cur = parseFloat(im.style.top) || 0; im.style.top = Math.max(minTop, Math.min(0, cur + move)) + 'px'; return; } // 情况2:容器超出视口 → 整体平移容器,查看溢出部分 const overY = c.offsetHeight - window.innerHeight; if (overY > 0) { const limit = overY / 2; // 居中定位下,上下各溢出 overY/2 let cur = (instance.panY || 0) + move; cur = Math.max(-limit, Math.min(limit, cur)); instance.panY = cur; c.style.transform = `translate(-50%, calc(-50% + ${cur}px))`; } } }; const FSM = { get state() { return state; }, hasActiveZoom() { return state === S.SHOWING || state === S.ACTIVE; }, getSourceRect() { return instance && instance.sourceImg && instance.sourceImg.isConnected ? instance.sourceImg.getBoundingClientRect() : null; }, heartbeat() { if (!isEnabled || isHomepageZoomDisabled()) { FSM.dispatch('RESET'); return; } // ★ 窗口切换后的恢复保护:必须等用户真实移动鼠标后才恢复 hover 裁决。 // 防止 Alt+Tab / 原生窗口切换时浏览器仅收到 focus/mouseover 等事件, // 使用旧的 lastMouse 坐标再次触发放大,造成“先收起、再自动放大”。 if (resumeBlockedUntilMouseMove) return; // ★ 窗口失焦且关闭了“失焦时收起”:保留当前预览,等待切回。 // 文件管理器等原生窗口覆盖浏览器时,浏览器可能收到 mouseout + blur; // 此时不能把“窗口失焦”误判成“鼠标离开原图”。 if (!pointerInWindow) { if (!browserWindowFocused && !config.blurDismiss) return; if (state === S.PENDING) actions.cancel(); else if ((state === S.SHOWING || state === S.ACTIVE) && instance) actions.beginFade(); return; } const x = lastMouse.x, y = lastMouse.y; if (x < 0) return; if (state === S.PENDING && pendingImg) { if (canTriggerNow(pendingImg, x, y)) { pendingFails = 0; return; } pendingFails++; if (pendingFails >= 2) actions.cancel(); return; } if (state === S.SHOWING || state === S.ACTIVE) { if (!instance) { state = S.IDLE; return; } // 只要在原图区域内就保持;离开原图(即使在放大图上方)→ 淡出 const sRect = instance.sourceImg.isConnected ? instance.sourceImg.getBoundingClientRect() : null; if (!inRect(x, y, sRect)) actions.beginFade(); return; } }, orphanCheck() { if (instance && instance.sourceImg && !instance.sourceImg.isConnected) actions.beginFade(); }, dispatch(event, payload) { switch (event) { case 'HOVER': { const img = payload.img, x = payload.x, y = payload.y; if (!isEnabled || isHomepageZoomDisabled()) break; if (config.avoidClickConflict && isImageInLightboxMode()) break; if (!img || !img.isConnected) break; const anchor = canTriggerNow(img, x, y); if (!anchor) { if (state === S.PENDING && pendingImg === img) actions.cancel(); break; } if (state === S.SHOWING || state === S.ACTIVE) { if (!instance) break; if (img === instance.sourceImg) break; // 还是当前这张 → 保持不动 const zr = instance.container.getBoundingClientRect(); if (inRect(x, y, zr)) { if (Date.now() - lastMouse.t < 300) { actions.beginFade(); actions.startPending(img); } break; } actions.beginFade(); actions.startPending(img); break; } if (state === S.PENDING) { if (pendingImg !== img) actions.startPending(img); } else { actions.startPending(img); // IDLE / FADING } break; } case 'HOVER_NONE': { const x = payload.x, y = payload.y; // force=true 时跳过"lastMouse 在原图矩形内"的保活检查: // 鼠标离开浏览器/窗口失焦时 lastMouse 是陈旧坐标,光标实际已不在图上 if (!(payload && payload.force) && (state === S.SHOWING || state === S.ACTIVE) && instance) { const sr = instance.sourceImg.isConnected ? instance.sourceImg.getBoundingClientRect() : null; if (inRect(x, y, sr)) break; } if (state === S.PENDING) actions.cancel(); else if (state === S.SHOWING || state === S.ACTIVE) actions.beginFade(); break; } case 'TIMER_FIRE': { if (state === S.PENDING && pendingImg === payload) { if (canTriggerNow(payload, lastMouse.x, lastMouse.y)) { actions.show(payload); break; } const img = payload; const r = img.isConnected ? img.getBoundingClientRect() : null; const stillOk = r && isImgVisibleNow(img) && !isImgClippedAway(img) && lastMouse.x >= r.left - 120 && lastMouse.x <= r.right + 120 && lastMouse.y >= r.top - 120 && lastMouse.y <= r.bottom + 120; if (stillOk && !pendingGraceUsed) { pendingGraceUsed = true; pendingFails = 0; if (pendingTimer) clearTimeout(pendingTimer); pendingTimer = setTimeout(() => FSM.dispatch('TIMER_FIRE', img), Math.max(300, config.delay)); } else { actions.cancel(); } } break; } case 'LOADED': actions.activate(payload); break; case 'ERROR': actions.onError(payload); break; case 'WHEEL': { if (state === S.ACTIVE && instance && isEnabled) { payload.preventDefault && payload.preventDefault(); payload.stopPropagation && payload.stopPropagation(); lastWheelEvent = payload; if (!wheelTicking) { wheelTicking = true; requestAnimationFrame(() => { wheelTicking = false; if (!lastWheelEvent || state !== S.ACTIVE || !instance) return; if (config.wheelZoom) actions.zoomByWheel(lastWheelEvent); else actions.pan(lastWheelEvent); }); } return true; } return false; } case 'DISMISS': { if (state === S.PENDING) actions.cancel(); else if (state === S.SHOWING || state === S.ACTIVE) actions.beginFade(); break; } case 'RESET': { generation++; if (currentAbort) { currentAbort.abort(); currentAbort = null; } clearPending(); if (instance) { const c = instance.container; const zImg = c.querySelector('img'); if (zImg && zImg.__zoomBlobUrl) { URL.revokeObjectURL(zImg.__zoomBlobUrl); zImg.__zoomBlobUrl = null; } if (c.parentNode) c.parentNode.removeChild(c); instance = null; } document.querySelectorAll('.image-zoom-container[data-iz-owner="fsm"]').forEach(c => { const zImg = c.querySelector('img'); if (zImg && zImg.__zoomBlobUrl) { URL.revokeObjectURL(zImg.__zoomBlobUrl); zImg.__zoomBlobUrl = null; } c.remove(); }); state = S.IDLE; wheelManager.sync(); break; } } return false; } }; return FSM; })(); const wheelManager = (function() { let attached = false; function handler(e) { if (zoomFSM.dispatch('WHEEL', e)) return; bilibiliVolumeModule.onWheel(e); } function sync() { const need = zoomFSM.hasActiveZoom() || bilibiliVolumeModule.isFullscreenActive(); if (need && !attached) { document.addEventListener('wheel', handler, { capture: true, passive: false }); attached = true; } else if (!need && attached) { document.removeEventListener('wheel', handler, { capture: true }); attached = false; } } document.addEventListener('fullscreenchange', sync); document.addEventListener('webkitfullscreenchange', sync); return { sync }; })(); function setupGlobalHoverStream() { // 流1:mouseover(前沿节流 + 事件自带坐标)—— 快速、跟随移动 document.addEventListener('mouseover', throttleLeading((e) => { if (lastMouse.x < 0) { lastMouse.x = e.clientX; lastMouse.y = e.clientY; } if (resumeBlockedUntilMouseMove) return; resolveCursorTarget(e.clientX, e.clientY, e.target); }, 60), true); // 流2:★ 停稳裁决器 —— 鼠标停止移动 ~120ms 后主动裁决一次。 // 兜底 mouseover 被节流丢弃 / 未派发 / target 不含 img 的所有情况。 const stopResolve = debounce(() => { if (document.hidden) return; if (resumeBlockedUntilMouseMove) return; if (!pointerInWindow) return; // ★ 光标已被其他窗口覆盖,不裁决 const x = lastMouse.x, y = lastMouse.y; if (x < 0) return; resolveCursorTarget(x, y, document.elementFromPoint(x, y)); }, 120); document.addEventListener('mousemove', () => { stopResolve(); }, { passive: true }); // ★ 鼠标真正离开浏览器窗口。 // 原生窗口(文件管理器等)覆盖浏览器时,mouseout 可能和 blur 一起出现。 // 关闭“窗口失焦时收起”后,先短暂等待 blur 事件完成;如果确认浏览器已失焦, // 则把这次 mouseout 视为“窗口切换”,保留当前预览。 document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { pointerInWindow = false; resumeBlockedUntilMouseMove = true; // ★ 关闭“窗口失焦时收起”后,mouseout 绝不能单独收起当前预览。 // 浏览器/原生窗口切换时,mouseout 与 blur 的先后顺序并不固定; // 如果这里立即或异步执行 HOVER_NONE,就会把“切换应用”误判成“离开原图”。 // 此模式下只记录窗口外状态,等真正的物理 mousemove 回来后再恢复裁决。 if (!config.blurDismiss) return; zoomFSM.dispatch('HOVER_NONE', { x: lastMouse.x, y: lastMouse.y, force: true }); } }, true); // ★ 浏览器窗口失去焦点。 // 开启:失焦时收起预览。 // 关闭:仅记录失焦,不主动收起;这样 Alt+Tab、点击其他应用、文件管理器覆盖等 // 场景都可以保留当前预览,切回浏览器后继续查看。 window.addEventListener('blur', () => { browserWindowFocused = false; resumeBlockedUntilMouseMove = true; if (config.blurDismiss) { zoomFSM.dispatch('HOVER_NONE', { x: lastMouse.x, y: lastMouse.y, force: true }); } }); window.addEventListener('focus', () => { browserWindowFocused = true; // 不在 focus 时恢复 pointerInWindow;必须等真实 mousemove。 // 否则 lastMouse 仍是切换应用前的旧坐标,会立即再次命中原图。 pointerInWindow = false; resumeBlockedUntilMouseMove = true; // 失焦期间没有主动收起时,切回浏览器后重新核对光标位置。 // 如果光标仍在原图区域,继续保留;如果已经离开,则立即收起。 if (!config.blurDismiss && zoomFSM.hasActiveZoom()) { const x = lastMouse.x, y = lastMouse.y; if (x >= 0) { const rect = zoomFSM.getSourceRect ? zoomFSM.getSourceRect() : null; if (!rect || !inRect(x, y, rect)) { zoomFSM.dispatch('HOVER_NONE', { x, y, force: true }); } } } }); } function setupHeartbeat() { //后台标签页跳过心跳。 // heartbeat/orphanCheck 每 150ms/1500ms 都做 getBoundingClientRect(强制 layout), // 之前在后台标签页持续空转,耗电并干扰主线程; // 后台布局变化(SPA 预渲染、轮播定时器)还可能触发错误的 beginFade 决策。 setInterval(() => { if (!document.hidden) zoomFSM.heartbeat(); }, HEARTBEAT_MS); setInterval(() => { if (!document.hidden) zoomFSM.orphanCheck(); }, 1500); } // 6. ★ 全局事件流 + 停稳裁决器 // ================ // 7/8. ★ 背景图悬停模块 // ================ // ================ // 9. 动态图片观察器 // ================ // 5.6.25 - 动态图片观察器 // 不改观察/MutationObserver 行为。 // ================ // 9. 动态图片观察器 // ================ let lazyImageObserver = null; function observeImage(img) { if (!lazyImageObserver) { lazyImageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { lazyImageObserver.unobserve(entry.target); processImage(entry.target); } }); }, { rootMargin: '300px 0px' }); } lazyImageObserver.observe(img); } function initImages() { if (isHomepageZoomDisabled()) return; document.querySelectorAll('img:not(.image-zoom-processed)').forEach(observeImage); } function startObserver() { let processingQueue = false; let pendingMutations = null; const observer = new MutationObserver(mutations => { if (!isEnabled || isHomepageZoomDisabled()) return; //处理期间到达的 mutation 不再丢弃。 if (processingQueue) { if (!pendingMutations) pendingMutations = []; for (const m of mutations) pendingMutations.push(m); // ★ 避免大数组 spread 的 RangeError return; } processingQueue = true; const processBatch = (batch) => { const nodes = new Set(); // ★ 自有 UI 隔离:放大层容器及其内部节点不作为页面图片登记对象 const isOwnUI = (el) => !!(el.closest && el.closest('.image-zoom-container')); batch.forEach(mutation => { if (mutation.type === 'attributes' && mutation.target.tagName === 'IMG' && !mutation.target.classList.contains('image-zoom-processed') && !isOwnUI(mutation.target)) { nodes.add(mutation.target); } else if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (isOwnUI(node)) return; if (node.tagName === 'IMG' && !node.classList.contains('image-zoom-processed')) { nodes.add(node); } else if (node.querySelectorAll) { node.querySelectorAll('img:not(.image-zoom-processed)').forEach(i => { if (!isOwnUI(i)) nodes.add(i); }); } }); } }); nodes.forEach(img => observeImage(img)); }; const run = (batch) => { let flushed = false; const flush = () => { if (flushed) return; flushed = true; processBatch(batch); processingQueue = false; if (pendingMutations && pendingMutations.length) { const nextBatch = pendingMutations; pendingMutations = null; processingQueue = true; run(nextBatch); } }; // 可见页仍走 rAF(保持原有帧对齐时序)。后台标签页不执行 rAF, // 若只依赖它,processingQueue 会一直停在 true,后续 MutationRecord // 只能不断堆进 pendingMutations(无上限,且会长期持有已删除节点的引用)。 if (document.hidden) { setTimeout(flush, 16); } else { requestAnimationFrame(flush); setTimeout(() => { if (document.hidden) flush(); }, 250); } }; run(mutations); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'data-src', 'srcset'] }); return observer; } // ============================================================================ // Lightbox observer // 处理网页自己的点击放大 / Lightbox: // 1. 灯箱打开时收起已有 hover 放大实例; // 2. 灯箱内部图片不再触发 hover 放大; // 3. 兼容“仅插入灯箱 DOM、不修改 body class”的网站实现。 // 4. 兼容没有固定 class 的通用全屏图片浮层。 // ============================================================================ const LIGHTBOX_CLASSES = ['lightbox-open', 'fancybox-open', 'modal-open', 'zoom-overlay-open']; // 某些站点的灯箱结构为 #imgzoom > #imgzoom_zoomlayer > #imgzoom_zoom。 const LIGHTBOX_SELECTORS = [ '.fancybox-container', '.fancybox-overlay', '.fancybox-bg', '.fancybox__container', '.pswp', '.pswp__bg', '.lg-backdrop', '.lg-outer', '.viewer-container', '.viewer-backdrop', '[data-fancybox-container]', '#imgzoom', '#imgzoom_zoomlayer', '#imgzoom_zoom' ]; // 预拼一次即可:isElementInsideLightbox / isRelevantMutation 每次调用都会用到, // 而 isRelevantMutation 对每条 mutation 最多要用 4 次,现场 join 属于纯浪费。 const LIGHTBOX_SELECTOR_STR = LIGHTBOX_SELECTORS.join(','); function isVisibleLightboxElement(el) { if (!el || !el.isConnected) return false; try { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; if (parseFloat(cs.opacity) < 0.05) return false; const r = el.getBoundingClientRect(); return r.width >= 120 && r.height >= 120; } catch (e) { return false; } } function hasLightboxKeyword(el) { if (!el || !el.getAttribute) return false; const cls = typeof el.className === 'string' ? el.className : (el.getAttribute('class') || ''); const id = el.id || ''; const text = `${cls} ${id}`.toLowerCase(); return /lightbox|fancybox|photoswipe|photo[-_ ]?viewer|image[-_ ]?(viewer|preview)|gallery[-_ ]?(modal|viewer)|preview[-_ ]?modal/.test(text); } function isElementInsideLightbox(target) { if (!target || !target.closest) return false; try { // 明确的 Lightbox / 图片查看器结构优先。 if (target.closest(LIGHTBOX_SELECTOR_STR)) return true; // aria-modal / role=dialog 本身不能证明是图片灯箱。 // 只有对话框具备明确的图片查看器语义,或内部存在可见的大尺寸媒体, // 才把它视为 Lightbox,避免误伤普通网站弹窗。 const dialog = target.closest('[role="dialog"],[aria-modal="true"]'); if (dialog) { if (hasLightboxKeyword(dialog)) return true; // ★ 仅"覆盖式"对话框 + 可见大图才算灯箱。 // role="dialog"/aria-modal 常被 SPA 当作普通内容包裹层;若只看"里面有没有大图", // 会把包住整页卡片的 dialog 误判为灯箱,从而在找图之前截断流程。 // 另外遍历全部媒体,避免只看到第一个(可能是隐藏小图标)就下结论。 let overlayLike = false; try { const dcs = getComputedStyle(dialog); overlayLike = dcs.position === 'fixed' || dcs.position === 'absolute'; } catch (e) { } if (overlayLike && dialog.querySelectorAll) { for (const m of dialog.querySelectorAll('img,video,canvas')) { if (isVisibleLightboxElement(m)) return true; } } } } catch (e) { } return false; } // ★ Lightbox 全局状态缓存:鼠标移动是高频路径,不再每次都全页面 // querySelectorAll + getComputedStyle。缓存只保存“全局是否存在可见灯箱”, // target 是否位于灯箱内部仍由 isElementInsideLightbox() 实时判断。 let lightboxGlobalOpen = null; function scanGlobalLightboxState() { if (LIGHTBOX_CLASSES.some(c => document.body.classList.contains(c) || document.documentElement.classList.contains(c) )) return true; for (const selector of LIGHTBOX_SELECTORS) { try { const nodes = document.querySelectorAll(selector); for (const el of nodes) { if (isVisibleLightboxElement(el)) return true; } } catch (e) { } } try { const nodes = document.querySelectorAll( '[class*="lightbox"][class*="open" i],' + '[class*="lightbox"][class*="active" i],' + '[class*="fancybox"][class*="open" i],' + '[class*="viewer"][class*="open" i]' ); for (const el of nodes) { if (isVisibleLightboxElement(el)) return true; } } catch (e) { } return false; } function invalidateLightboxState() { lightboxGlobalOpen = null; } function isImageInLightboxMode(target) { if (target && isElementInsideLightbox(target)) return true; if (lightboxGlobalOpen === null) lightboxGlobalOpen = scanGlobalLightboxState(); return lightboxGlobalOpen; } function setupLightboxObserver() { let checkScheduled = false; let observer = null; const dismissIfOpen = () => { checkScheduled = false; if (isImageInLightboxMode()) zoomFSM.dispatch('DISMISS'); }; const scheduleCheck = () => { if (checkScheduled) return; checkScheduled = true; requestAnimationFrame(dismissIfOpen); }; const isRelevantMutation = (mutation) => { if (mutation.type === 'attributes') { const target = mutation.target; if (!target || target.nodeType !== Node.ELEMENT_NODE) return false; // Lightbox 状态只可能通过这些属性发生可见变化。 return target === document.body || target === document.documentElement || !!target.closest(LIGHTBOX_SELECTOR_STR) || !!target.closest('[role="dialog"],[aria-modal="true"]') || target.matches?.('[data-fancybox],[data-lightbox],.lightbox,.fancybox,.pswp,.viewer-container'); } if (mutation.type === 'childList') { // 新增和移除都可能改变灯箱状态;旧版只检查 addedNodes, // 导致灯箱被移除后缓存/检测可能继续保留旧状态。 const nodes = [...mutation.addedNodes, ...mutation.removedNodes]; for (const node of nodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (node.matches?.(LIGHTBOX_SELECTOR_STR)) return true; if (node.matches?.('[role="dialog"],[aria-modal="true"],img,[data-fancybox],[data-lightbox],.lightbox,.fancybox,.pswp,.viewer-container')) return true; if (node.querySelector?.(LIGHTBOX_SELECTOR_STR)) return true; if (node.querySelector?.('[role="dialog"],[aria-modal="true"],[data-fancybox],[data-lightbox],.lightbox,.fancybox,.pswp,.viewer-container')) return true; } return false; } return false; }; observer = new MutationObserver(mutations => { // Observer 仍监听页面结构,但真正的 Lightbox 检测只在 // 可能影响 Lightbox 状态的 mutation 上触发,并且每帧最多检测一次。 for (const mutation of mutations) { if (isRelevantMutation(mutation)) { invalidateLightboxState(); scheduleCheck(); break; } } }); observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'style', 'aria-hidden', 'aria-modal'] }); // ★ 测试修复:点击图片打开帖子前,先立即结束当前放大状态。 // 某些站点在 click 之前就会执行导航/新标签页逻辑,单靠 click 可能来不及。 // 这里只处理当前放大源图片或其链接,不改变普通页面点击行为。 const dismissZoomBeforeOpen = (e) => { if (!zoomFSM.hasActiveZoom()) return; // 当前已有放大预览时,只要按下位置仍在“原始图片区域”内, // 就把这次操作视为用户正在点击原图/原图所在链接。 // 不再依赖 event.target 必须是 img;部分网站的点击层、链接层、 // 图片包装器或事件代理会让 target 变成 div/a/span,导致旧版漏掉。 const t = e.target; if (t && t.closest && t.closest('.image-zoom-container')) return; const sourceRect = zoomFSM.getSourceRect ? zoomFSM.getSourceRect() : null; if (!sourceRect) return; const x = Number(e.clientX); const y = Number(e.clientY); if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (!inRect(x, y, sourceRect)) return; // pointerdown / mousedown 在导航、新标签、站点 click 代理之前执行, // 因此无论 blurDismiss 开关状态如何,都先清掉旧放大实例。 zoomFSM.dispatch('DISMISS'); }; // pointerdown 优先于 click:用户点击图片准备打开帖子时先销毁预览。 // 现代浏览器使用 Pointer Events,因此不再同时监听 mousedown,避免一次点击 // 连续进入两次 DISMISS;仅在极少数不支持 PointerEvent 的环境回退到 mousedown。 if ('PointerEvent' in window) { document.addEventListener('pointerdown', dismissZoomBeforeOpen, true); } else { document.addEventListener('mousedown', dismissZoomBeforeOpen, true); } document.addEventListener('click', (e) => { const t = e.target; if (!t || !t.closest) return; const trigger = t.closest( 'img,[data-fancybox],[data-lightbox],.zoomable,.lightbox,.gallery-item,.fancybox,' + '[onclick*="zoom"],[onclick*="lightbox"],[onclick*="gallery"],[onclick*="preview"]' ); if (!trigger) return; zoomFSM.dispatch('DISMISS'); setTimeout(dismissIfOpen, 0); }, true); dismissIfOpen(); } // setupLightboxObserver(灯箱观察) // ================ // 10. 滚轮管理器 // ================ // ================ // Bilibili player helper. Behavior kept identical to 5.6.26. const bilibiliVolumeModule = (function() { let enabled = storageGet('bilibili_volume_enabled', true); let toast = null; function isInFullscreenMode() { if (document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement) return true; if (document.body.classList.contains('player-mode-webfullscreen')) return true; const player = document.querySelector('.bpx-player-container'); return !!(player && player.classList.contains('state-fullscreen')); } function findVideoElement() { const fe = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement; if (fe) { const v = fe.querySelector('video'); if (v) return v; } return document.querySelector('.bpx-player-container video, video'); } function applyVolume(video, v) { const c = Math.max(0, Math.min(1, v)); try { const p = window.player; if (p && typeof p.setVolume === 'function') { p.setVolume(Math.round(c * 100)); if (c > 0) { typeof p.setMute === 'function' ? p.setMute(false) : (video.muted = false); } return; } } catch (e) { } video.volume = c; video.muted = false; } function getVolume(video) { try { const p = window.player; if (p && typeof p.getVolume === 'function') return p.getVolume() / 100; } catch (e) { } return video.volume; } function volIcon(volume) { if (volume === 0) return ``; return ``; } function showVolumeToast(volume) { if (!toast) { toast = document.createElement('div'); toast.id = 'bilibili-volume-toast'; toast.style.cssText = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%); background:rgba(255,255,255,.9);color:#333;padding:8px 16px;border-radius:8px;z-index:2147483647; font-size:26px;font-weight:300;opacity:0;transition:opacity .3s;pointer-events:none; box-shadow:0 4px 20px rgba(0,0,0,.2);min-width:90px;text-align:center;backdrop-filter:blur(10px); display:flex;align-items:center;justify-content:center;gap:8px;`; } const parent = document.fullscreenElement || document.body; if (!toast.parentNode || toast.parentNode !== parent) { if (toast.parentNode) toast.parentNode.removeChild(toast); parent.appendChild(toast); } toast.innerHTML = volIcon(volume) + `${volume === 0 ? '静音' : Math.round(volume * 100) + '%'}`; toast.style.opacity = '1'; if (toast.timeoutId) clearTimeout(toast.timeoutId); toast.timeoutId = setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => { if (toast && toast.parentNode) toast.parentNode.removeChild(toast); }, 300); }, 2000); } function isBilibiliHost() { return window.location.hostname.includes('bilibili.com'); } function onWheel(e) { // ★ 必须带域名判断:onWheel 是滚轮事件链的末端(wheelManager → onWheel), // 少了这道门槛,任何站点只要进入全屏且页面里存在